diff --git a/CHANGELOG.md b/CHANGELOG.md index 52cf18db1..7eafb87f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- `apm marketplace publish` command and consumer-repo fan-out workflow; consumers should run `apm install --update` instead. (#1134) +- `apm marketplace doctor` subcommand alias (deprecated); use `apm doctor` instead. (#1134) + ## [0.20.0] - 2026-06-11 ### Added diff --git a/docs/src/content/docs/producer/publish-to-a-marketplace.md b/docs/src/content/docs/producer/publish-to-a-marketplace.md index 204c99cb3..4a2d86e91 100644 --- a/docs/src/content/docs/producer/publish-to-a-marketplace.md +++ b/docs/src/content/docs/producer/publish-to-a-marketplace.md @@ -201,46 +201,13 @@ no `dependencies:` block produce only `marketplace.json`. See ```bash apm marketplace check # every package's ref/range resolves -apm marketplace doctor # local environment diagnostics +apm doctor # local environment diagnostics apm marketplace outdated # packages with newer matching tags ``` `check` is the gate to run in CI: a missing tag or unresolvable range exits non-zero before you push the release commit. -## Publish updates to pinned consumers - -`apm marketplace publish` is the optional fan-out: it opens PRs -against a list of consumer repos that pin the previous marketplace -version, bumping each one to the version you just released. - -```yaml -# consumer-targets.yml -targets: - - repo: acme-org/service-a - branch: main - - repo: acme-org/service-b - branch: develop - path_in_repo: apm.yml # default -``` - -```bash -apm marketplace publish --dry-run # preview -apm marketplace publish --yes # push branches and open PRs -apm marketplace publish --no-pr # push branches, skip gh PR creation -``` - -It clones each target, edits its `apm.yml` to point at the new -marketplace ref, pushes a feature branch, and opens a PR via `gh`. -State is journaled to `.apm/publish-state.json`. Failures in one -target do not abort the others; the exit code is non-zero if any -target failed. - -This flow assumes `gh` is authenticated and the runner has push -access to every target -- it is targeted at internal/org marketplaces -where you control both sides. Public marketplaces should rely on -consumers running `apm install --update` on their own cadence. - ## Pitfalls - **`packages:` not `plugins:`** in the `apm.yml` source. The diff --git a/docs/src/content/docs/reference/cli/marketplace.md b/docs/src/content/docs/reference/cli/marketplace.md index 5ba0692f5..9a526fd04 100644 --- a/docs/src/content/docs/reference/cli/marketplace.md +++ b/docs/src/content/docs/reference/cli/marketplace.md @@ -220,11 +220,6 @@ apm marketplace audit my-marketplace apm marketplace audit my-marketplace --strict ``` -### `apm marketplace doctor` - -Run environment diagnostics for marketplace publishing: git binary, -network reachability, auth (`gh`/PAT), and config sanity. - ### `apm marketplace outdated` Show packages in the authoring config that have newer upstream @@ -252,27 +247,6 @@ layouts (`v{version}`, `{name}_v{version}`, `{name}--v{version}`, etc.) automatically. Set `tag_pattern` explicitly when your producer uses a different layout. -### `apm marketplace publish` - -Push marketplace updates to one or more **consumer** repositories, -optionally opening pull requests. - -```bash -apm marketplace publish --dry-run -apm marketplace publish --targets ./consumer-targets.yml --draft -``` - -| Flag | Description | -|---|---| -| `--targets FILE` | Path to consumer-targets YAML. Default: `./consumer-targets.yml`. | -| `--dry-run` | Preview without pushing or opening PRs. | -| `--no-pr` | Push branches but skip PR creation. | -| `--draft` | Open PRs as drafts. | -| `--allow-downgrade` | Permit version downgrades. | -| `--allow-ref-change` | Permit switching ref types (e.g. tag to SHA). | -| `--parallel N` | Maximum concurrent target updates. Default: `4`. | -| `--yes`, `-y` | Skip the confirmation prompt. | - ### `apm marketplace package add SOURCE` Add a package entry to the authoring config. `SOURCE` is a git repo diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index dd29cb1ba..7ae0e7ef2 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -147,8 +147,7 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set | `apm marketplace outdated` | Report upgradable plugins, range-aware; respects `tag_pattern` and common monorepo tag layouts | `--offline`, `--include-prerelease`, `-v` | | `apm marketplace check` | Validate the `marketplace:` block and verify refs resolve | `--offline`, `-v` | | `apm marketplace audit NAME` | Supply-chain audit: warn when plugin transitive deps bypass marketplace pinning | `--strict` (CI exit-1 on bypass), `-v` | -| `apm marketplace doctor` | Diagnose git, network, auth, marketplace config readiness, and (when a `marketplace:` block is present) **format coverage** -- which output profiles are configured vs. supported, so producers can spot easy reach wins (e.g. add `codex: {}` to also publish for Codex consumers). All marketplace-specific rows are informational and never affect exit code. | `-v` | -| `apm marketplace publish` | Open PRs on consumer repos from `consumer-targets.yml` | `--targets PATH`, `--dry-run`, `--no-pr`, `--draft`, `--allow-downgrade`, `--allow-ref-change`, `--parallel N`, `-y` | +| `apm doctor` | Diagnose git, network, auth, marketplace config readiness, and (when a `marketplace:` block is present) **format coverage** -- which output profiles are configured vs. supported, so producers can spot easy reach wins (e.g. add `codex: {}` to also publish for Codex consumers). All marketplace-specific rows are informational and never affect exit code. | `-v` | | `apm marketplace package add ` | Add a plugin entry to `marketplace.plugins` (source accepts `owner/repo` or `./path`) | `--name`, `--version`, `--ref` (mutable refs auto-resolved to SHA), `-d`/`--description`, `-s`/`--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease`, `--no-verify` | | `apm marketplace package set ` | Update fields on an existing plugin entry | `--version`, `--ref` (mutable refs auto-resolved to SHA), `--description`, `--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease` | | `apm marketplace package remove ` | Remove a plugin entry from `marketplace.plugins` | `--yes` | diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 54171786f..65ea570a6 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -633,7 +633,7 @@ is a hard error -- run `migrate` to consolidate. See [docs/guides/marketplace-authoring](../../../../../docs/src/content/docs/guides/marketplace-authoring.md) for the complete maintainer workflow (quickstart, version ranges, `check`, -`doctor`, `outdated`, and `publish`). +`doctor`, and `outdated`). ## Org-wide packages diff --git a/src/apm_cli/commands/doctor.py b/src/apm_cli/commands/doctor.py index ced3803e9..d039a1799 100644 --- a/src/apm_cli/commands/doctor.py +++ b/src/apm_cli/commands/doctor.py @@ -1,11 +1,9 @@ """``apm doctor`` top-level command. Thin Click wrapper around :func:`apm_cli.commands.marketplace.doctor.run_doctor`. -The diagnostics are owned by the marketplace doctor module today because that -is where the existing implementation lives; promoting the entry point to the -top level is a discoverability fix without scope expansion. Future PRs may -add additional domains (lockfile, cache, runtime, config) by extending -``run_doctor`` -- each behind its own scope justification. +Thin Click wrapper around the marketplace doctor module where the existing +implementation lives. Future PRs may add additional domains (lockfile, +cache, runtime, config) by extending ``run_doctor``. """ from __future__ import annotations diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index e5ec2fafa..5a830bec0 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -17,7 +17,6 @@ from urllib.parse import urlsplit, urlunsplit import click -import yaml from ...core.command_logger import CommandLogger from ...marketplace.builder import BuildOptions, BuildReport, MarketplaceBuilder, ResolvedPackage @@ -38,14 +37,6 @@ load_marketplace_config, migrate_marketplace_yml, ) -from ...marketplace.pr_integration import PrIntegrator, PrResult, PrState -from ...marketplace.publisher import ( - ConsumerTarget, - MarketplacePublisher, - PublishOutcome, - PublishPlan, - TargetResult, -) from ...marketplace.ref_resolver import RefResolver, RemoteRef from ...marketplace.semver import SemVer, parse_semver, satisfies_range from ...marketplace.yml_schema import load_marketplace_yml @@ -92,8 +83,6 @@ class MarketplaceGroup(click.Group): "check", "outdated", "audit", - "doctor", - "publish", "package", "migrate", ] @@ -1349,223 +1338,6 @@ def _render_doctor_table(logger, checks): console.print(table) -def _load_targets_file(path): - """Load and validate a consumer-targets YAML file. - - Returns a list of ``ConsumerTarget`` instances. - - Raises ``SystemExit`` on validation failures. - """ - try: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - return None, f"Invalid YAML in targets file: {exc}" - except OSError as exc: - return None, f"Cannot read targets file: {exc}" - - if not isinstance(raw, dict) or "targets" not in raw: - return None, "Targets file must contain a 'targets' key." - - raw_targets = raw["targets"] - if not isinstance(raw_targets, list) or not raw_targets: - return None, "Targets file must contain a non-empty 'targets' list." - - targets = [] - for idx, entry in enumerate(raw_targets): - if not isinstance(entry, dict): - return None, f"targets[{idx}] must be a mapping." - - repo = entry.get("repo") - if not repo or not isinstance(repo, str): - return None, f"targets[{idx}]: 'repo' is required (owner/name)." - - # Validate repo format: owner/name - parts = repo.split("/") - if len(parts) != 2 or not parts[0] or not parts[1]: - return None, f"targets[{idx}]: 'repo' must be 'owner/name', got '{repo}'." - - branch = entry.get("branch") - if not branch or not isinstance(branch, str): - return None, f"targets[{idx}]: 'branch' is required." - - path_in_repo = entry.get("path_in_repo", "apm.yml") - if not isinstance(path_in_repo, str) or not path_in_repo.strip(): - return None, f"targets[{idx}]: 'path_in_repo' must be a non-empty string." - - # Path safety check - try: - validate_path_segments( - path_in_repo, - context=f"targets[{idx}].path_in_repo", - ) - except PathTraversalError as exc: - return None, str(exc) - - targets.append( - ConsumerTarget( - repo=repo.strip(), - branch=branch.strip(), - path_in_repo=path_in_repo.strip(), - ) - ) - - return targets, None - - -def _render_publish_plan(logger, plan): - """Render the publish plan as a Rich panel + target table.""" - console = _get_console() - - plan_text = ( - f"Marketplace: {plan.marketplace_name}\n" - f"New version: {plan.marketplace_version}\n" - f"New ref: {plan.new_ref}\n" - f"Branch: {plan.branch_name}\n" - f"Targets: {len(plan.targets)}" - ) - - if not console: - logger.progress("Publish plan:", symbol="info") - for line in plan_text.splitlines(): - logger.tree_item(f" {line}") - click.echo() - for t in plan.targets: - logger.tree_item(f" [*] {t.repo} branch={t.branch} path={t.path_in_repo}") - return - - from rich.panel import Panel - from rich.table import Table - from rich.text import Text - - console.print() - console.print( - Panel( - plan_text, - title="Publish plan", - border_style="cyan", - ) - ) - - table = Table( - show_header=True, - header_style="bold cyan", - border_style="cyan", - ) - table.add_column("Repo", style="bold white", no_wrap=True) - table.add_column("Branch", style="cyan") - table.add_column("Path", style="dim") - table.add_column("Status", no_wrap=True, width=10) - - for t in plan.targets: - table.add_row(t.repo, t.branch, t.path_in_repo, Text("[*]")) - - console.print(table) - console.print() - - -def _render_publish_summary(logger, results, pr_results, no_pr, dry_run): - """Render the final publish summary table.""" - console = _get_console() - - # Build lookup for PR results by repo - pr_by_repo = {} - for pr_r in pr_results: - pr_by_repo[pr_r.target.repo] = pr_r - - updated_count = sum(1 for r in results if r.outcome == PublishOutcome.UPDATED) - failed_count = sum(1 for r in results if r.outcome == PublishOutcome.FAILED) - total = len(results) - - if not console: - click.echo() - for r in results: - icon = _outcome_symbol(r.outcome) - pr_info = "" - if not no_pr: - pr_r = pr_by_repo.get(r.target.repo) - if pr_r: - pr_info = f" PR: {pr_r.state.value}" - if pr_r.pr_number: - pr_info += f" #{pr_r.pr_number}" - logger.tree_item(f" {icon} {r.target.repo}: {r.outcome.value}{pr_info} -- {r.message}") - click.echo() - _render_publish_footer(logger, updated_count, failed_count, total, dry_run) - return - - from rich.table import Table - from rich.text import Text - - table = Table( - title="Publish Results", - show_header=True, - header_style="bold cyan", - border_style="cyan", - ) - table.add_column("Status", no_wrap=True, width=6) - table.add_column("Repo", style="bold white", no_wrap=True) - table.add_column("Outcome", style="white") - - if not no_pr: - table.add_column("PR State", style="white") - table.add_column("PR #", style="cyan", justify="right") - table.add_column("PR URL", style="dim") - - table.add_column("Message", style="dim", ratio=1) - - for r in results: - icon = _outcome_symbol(r.outcome) - row = [Text(icon), r.target.repo, r.outcome.value] - - if not no_pr: - pr_r = pr_by_repo.get(r.target.repo) - if pr_r: - row.append(pr_r.state.value) - row.append(str(pr_r.pr_number) if pr_r.pr_number else "--") - row.append(pr_r.pr_url or "--") - else: - row.extend(["--", "--", "--"]) - - row.append(r.message) - table.add_row(*row) - - console.print() - console.print(table) - console.print() - - _render_publish_footer(logger, updated_count, failed_count, total, dry_run) - - -def _outcome_symbol(outcome): - """Map a ``PublishOutcome`` to a bracket symbol.""" - if outcome == PublishOutcome.UPDATED: - return "[+]" - elif outcome == PublishOutcome.FAILED: - return "[x]" - elif outcome in ( - PublishOutcome.SKIPPED_DOWNGRADE, - PublishOutcome.SKIPPED_REF_CHANGE, - ): - return "[!]" - elif outcome == PublishOutcome.NO_CHANGE: - return "[*]" - return "[*]" - - -def _render_publish_footer(logger, updated, failed, total, dry_run): - """Render the footer success/warning line.""" - suffix = " (dry-run)" if dry_run else "" - if failed == 0: - logger.success( - f"Published {updated}/{total} targets{suffix}", - symbol="check", - ) - else: - logger.warning( - f"Published {updated}/{total} targets, {failed} failed{suffix}", - symbol="warning", - ) - - @click.command( name="search", help="Search plugins in a marketplace (QUERY@MARKETPLACE)", @@ -1665,11 +1437,9 @@ def search(expression, limit, verbose): from .audit import audit # noqa: E402 from .check import check # noqa: E402 -from .doctor import doctor # noqa: E402 from .init import init # noqa: E402 from .migrate import migrate # noqa: E402 from .outdated import outdated # noqa: E402 -from .publish import publish # noqa: E402 from .validate import validate # noqa: E402 # Public surface: the click group + per-command callables. Domain types are @@ -1681,34 +1451,25 @@ def search(expression, limit, verbose): "BuildOptions", "BuildReport", "ConfigSource", - "ConsumerTarget", "GitLsRemoteError", "HeadNotAllowedError", "MarketplaceBuilder", "MarketplaceGroup", "MarketplaceNotFoundError", - "MarketplacePublisher", "MarketplaceYmlError", "NoMatchingVersionError", "OfflineMissError", "PathTraversalError", - "PrIntegrator", - "PrResult", - "PrState", - "PublishOutcome", - "PublishPlan", "RefNotFoundError", "RefResolver", "RemoteRef", "ResolvedPackage", "SemVer", - "TargetResult", "add", "audit", "browse", "check", "detect_config_source", - "doctor", "init", "list_cmd", "load_marketplace_config", @@ -1719,7 +1480,6 @@ def search(expression, limit, verbose): "outdated", "package", "parse_semver", - "publish", "remove", "satisfies_range", "search", diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py index 918e70bd4..0f7b4f2b0 100644 --- a/src/apm_cli/commands/marketplace/doctor.py +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -1,13 +1,10 @@ -"""``apm doctor`` (and legacy ``apm marketplace doctor``) command implementation.""" +"""``apm doctor`` command implementation.""" from __future__ import annotations import subprocess -import sys from pathlib import Path -import click - from ...core.command_logger import CommandLogger from ...marketplace.errors import MarketplaceYmlError from ...marketplace.git_stderr import translate_git_stderr @@ -21,16 +18,14 @@ _DoctorCheck, _find_duplicate_names, _render_doctor_table, - marketplace, ) def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: """Execute the doctor diagnostics and return an exit code. - Shared between the top-level ``apm doctor`` command and the legacy - ``apm marketplace doctor`` alias so both surfaces produce identical - output. Returns ``0`` if all critical checks pass, ``1`` otherwise. + Called by the top-level ``apm doctor`` command. + Returns ``0`` if all critical checks pass, ``1`` otherwise. """ logger = CommandLogger(logger_name, verbose=verbose) checks = [] @@ -121,38 +116,7 @@ def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: ) ) - # Check 4: gh CLI availability (informational; only needed for publish) - gh_ok = False - gh_detail = "" - try: - result = subprocess.run( - ["gh", "--version"], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0: - gh_ok = True - gh_detail = result.stdout.strip().split("\n")[0] - else: - gh_detail = "gh CLI returned non-zero exit code" - except FileNotFoundError: - gh_detail = "gh CLI not found (install: https://cli.github.com/)" - except subprocess.TimeoutExpired: - gh_detail = "gh --version timed out" - except (subprocess.SubprocessError, OSError) as exc: - gh_detail = str(exc)[:60] - - checks.append( - _DoctorCheck( - name="gh CLI", - passed=gh_ok, - detail=gh_detail, - informational=True, - ) - ) - - # Check 5: marketplace config presence + parsability + # Check 4: marketplace config presence + parsability project_root = Path.cwd() apm_path = project_root / "apm.yml" legacy_path = project_root / "marketplace.yml" @@ -194,7 +158,7 @@ def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: ) ) - # Check 6: format coverage (informational; only when config is present) + # Check 5: format coverage (informational; only when config is present) if yml_obj is not None: configured = frozenset(getattr(yml_obj, "outputs", ()) or ()) supported = known_output_names() @@ -220,7 +184,7 @@ def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: ) ) - # Check 7: duplicate package names (defence-in-depth) + # Check 6: duplicate package names (defence-in-depth) if yml_obj is not None: dup_detail = _find_duplicate_names(yml_obj) if dup_detail: @@ -242,7 +206,7 @@ def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: ) ) - # Check 8: version alignment (informational; only when config is present) + # Check 7: version alignment (informational; only when config is present) if yml_obj is not None and hasattr(yml_obj, "versioning"): from ...marketplace.version_check import check_version_alignment @@ -280,26 +244,3 @@ def run_doctor(verbose: bool, *, logger_name: str = "doctor") -> int: if any(not c.passed for c in critical_checks): return 1 return 0 - - -@marketplace.command( - name="doctor", - help="DEPRECATED: use 'apm doctor' instead. Run environment diagnostics.", - hidden=True, -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def doctor(verbose): - """Deprecated alias for ``apm doctor``. - - Prints a one-line deprecation hint and forwards to :func:`run_doctor`. - The command stays functional for one release to give CI pipelines and - scripts time to migrate; it is hidden from ``apm marketplace --help`` - so new users discover the top-level form. - """ - click.echo( - "[!] 'apm marketplace doctor' is deprecated; use 'apm doctor' instead.", - err=True, - ) - exit_code = run_doctor(verbose, logger_name="marketplace-doctor") - if exit_code != 0: - sys.exit(exit_code) diff --git a/src/apm_cli/commands/marketplace/publish.py b/src/apm_cli/commands/marketplace/publish.py deleted file mode 100644 index 6820fe5a5..000000000 --- a/src/apm_cli/commands/marketplace/publish.py +++ /dev/null @@ -1,239 +0,0 @@ -"""``apm marketplace publish`` command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import click - -from ...core.command_logger import CommandLogger -from ...marketplace.pr_integration import PrIntegrator, PrResult, PrState -from ...marketplace.publisher import MarketplacePublisher, PublishOutcome -from .._helpers import _get_console, _is_interactive -from . import ( - _load_config_or_exit, - _load_targets_file, - _render_publish_plan, - _render_publish_summary, - marketplace, -) - - -@marketplace.command(help="Publish marketplace updates to consumer repositories") -@click.option( - "--targets", - "targets_file", - default=None, - type=click.Path(exists=False), - help="Path to consumer-targets YAML file (default: ./consumer-targets.yml)", -) -@click.option("--dry-run", is_flag=True, help="Preview without pushing or opening PRs") -@click.option("--no-pr", is_flag=True, help="Push branches but skip PR creation") -@click.option("--draft", is_flag=True, help="Create PRs as drafts") -@click.option("--allow-downgrade", is_flag=True, help="Allow version downgrades") -@click.option("--allow-ref-change", is_flag=True, help="Allow switching ref types") -@click.option( - "--parallel", - default=4, - show_default=True, - type=int, - help="Maximum number of concurrent target updates", -) -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def publish( - targets_file, - dry_run, - no_pr, - draft, - allow_downgrade, - allow_ref_change, - parallel, - yes, - verbose, -): - """Publish marketplace updates to consumer repositories.""" - logger = CommandLogger("marketplace-publish", verbose=verbose) - - # ------------------------------------------------------------------ - # 1. Pre-flight checks - # ------------------------------------------------------------------ - - # 1a. Load marketplace authoring config - _load_config_or_exit(logger) - - # 1b. Load marketplace.json - mkt_json_path = Path.cwd() / "marketplace.json" - if not mkt_json_path.exists(): - logger.error( - "marketplace.json not found. Run 'apm pack' first.", - symbol="error", - ) - sys.exit(1) - - # 1c. Load targets - if targets_file: - targets_path = Path(targets_file) - if not targets_path.exists(): - logger.error( - f"Targets file not found: {targets_file}", - symbol="error", - ) - sys.exit(1) - else: - targets_path = Path.cwd() / "consumer-targets.yml" - if not targets_path.exists(): - logger.error( - "No consumer-targets.yml found. " - "Create one or pass --targets .\n" - "\n" - "Example consumer-targets.yml:\n" - " targets:\n" - " - repo: acme-org/service-a\n" - " branch: main\n" - " - repo: acme-org/service-b\n" - " branch: develop", - symbol="error", - ) - sys.exit(1) - - targets, error = _load_targets_file(targets_path) - if error: - logger.error(error, symbol="error") - sys.exit(1) - - # 1d. Check gh availability (unless --no-pr) - pr = None - if not no_pr: - pr = PrIntegrator() - available, hint = pr.check_available() - if not available: - logger.error(hint, symbol="error") - sys.exit(1) - - # ------------------------------------------------------------------ - # 2. Plan and confirm - # ------------------------------------------------------------------ - - publisher = MarketplacePublisher(Path.cwd()) - plan = publisher.plan( - targets, - allow_downgrade=allow_downgrade, - allow_ref_change=allow_ref_change, - ) - - # Render publish plan - _render_publish_plan(logger, plan) - - # Confirmation logic - if not yes: - if not _is_interactive(): - logger.error( - "Non-interactive session: pass --yes to confirm the publish.", - symbol="error", - ) - sys.exit(1) - try: - if not click.confirm( - f"Confirm publish to {len(targets)} repositories?", - default=False, - ): - logger.progress("Publish cancelled.", symbol="info") - sys.exit(0) - except click.Abort: - logger.progress("Publish cancelled.", symbol="info") - sys.exit(0) - - if dry_run: - logger.progress( - "Dry run: no branches will be pushed and no PRs will be opened.", - symbol="info", - ) - - # ------------------------------------------------------------------ - # 3. Execute publish - # ------------------------------------------------------------------ - - results = publisher.execute(plan, dry_run=dry_run, parallel=parallel) - - # PR integration - pr_results = [] - if not no_pr: - if pr is None: - pr = PrIntegrator() - - for result in results: - if dry_run: - # In dry-run, preview what PR would do for UPDATED targets - if result.outcome == PublishOutcome.UPDATED: - pr_result = pr.open_or_update( - plan, - result.target, - result, - no_pr=False, - draft=draft, - dry_run=True, - ) - pr_results.append(pr_result) - else: - pr_results.append( - PrResult( - target=result.target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {result.outcome.value}", - ) - ) - else: # noqa: PLR5501 - if result.outcome == PublishOutcome.UPDATED: - pr_result = pr.open_or_update( - plan, - result.target, - result, - no_pr=False, - draft=draft, - dry_run=False, - ) - pr_results.append(pr_result) - else: - pr_results.append( - PrResult( - target=result.target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {result.outcome.value}", - ) - ) - - # ------------------------------------------------------------------ - # 4. Summary rendering - # ------------------------------------------------------------------ - - _render_publish_summary(logger, results, pr_results, no_pr, dry_run) - - # State file path -- use soft_wrap so the path is never split mid-word - # in narrow terminals (Rich would otherwise break at hyphens). - state_path = Path.cwd() / ".apm" / "publish-state.json" - try: - from rich.text import Text - - console = _get_console() - if console is not None: - console.print( - Text(f"[i] State file: {state_path}", no_wrap=True), - style="blue", - highlight=False, - soft_wrap=True, - ) - else: - logger.progress(f"State file: {state_path}", symbol="info") - except Exception: - logger.progress(f"State file: {state_path}", symbol="info") - - # Exit code - failed_count = sum(1 for r in results if r.outcome == PublishOutcome.FAILED) - if failed_count > 0: - sys.exit(1) diff --git a/src/apm_cli/marketplace/__init__.py b/src/apm_cli/marketplace/__init__.py index 51d651c11..ccc4b0799 100644 --- a/src/apm_cli/marketplace/__init__.py +++ b/src/apm_cli/marketplace/__init__.py @@ -27,14 +27,6 @@ MarketplaceSource, parse_marketplace_json, ) -from .pr_integration import PrIntegrator, PrResult, PrState -from .publisher import ( - ConsumerTarget, - MarketplacePublisher, - PublishOutcome, - PublishPlan, - TargetResult, -) from .ref_resolver import RefResolver, RemoteRef from .resolver import parse_marketplace_ref, resolve_marketplace_plugin from .semver import SemVer, parse_semver, satisfies_range @@ -54,7 +46,6 @@ "BuildError", "BuildOptions", "BuildReport", - "ConsumerTarget", "GitLsRemoteError", "HeadNotAllowedError", "MarketplaceBuild", @@ -66,7 +57,6 @@ "MarketplaceNotFoundError", "MarketplaceOwner", "MarketplacePlugin", - "MarketplacePublisher", "MarketplaceSource", "MarketplaceYml", "MarketplaceYmlError", @@ -74,17 +64,11 @@ "OfflineMissError", "PackageEntry", "PluginNotFoundError", - "PrIntegrator", - "PrResult", - "PrState", - "PublishOutcome", - "PublishPlan", "RefNotFoundError", "RefResolver", "RemoteRef", "ResolvedPackage", "SemVer", - "TargetResult", "build_tag_regex", "load_marketplace_from_apm_yml", "load_marketplace_from_legacy_yml", diff --git a/src/apm_cli/marketplace/git_stderr.py b/src/apm_cli/marketplace/git_stderr.py index 57e0bfa09..96641a42c 100644 --- a/src/apm_cli/marketplace/git_stderr.py +++ b/src/apm_cli/marketplace/git_stderr.py @@ -144,9 +144,7 @@ def _build_summary(kind: GitErrorKind, operation: str, exit_code: int | None) -> def _build_hint(kind: GitErrorKind, operation: str, remote: str | None) -> str: """Build a one-line actionable ASCII hint.""" if kind == GitErrorKind.AUTH: - return ( - "Check your GITHUB_TOKEN / gh auth / SSH key. Run 'apm marketplace doctor' to diagnose." - ) + return "Check your GITHUB_TOKEN / gh auth / SSH key. Run 'apm doctor' to diagnose." if kind == GitErrorKind.NOT_FOUND: remote_label = f"'{remote}'" if remote else "the remote" return f"Verify the remote {remote_label} exists and the ref is spelled correctly." diff --git a/src/apm_cli/marketplace/pr_integration.py b/src/apm_cli/marketplace/pr_integration.py deleted file mode 100644 index df037c234..000000000 --- a/src/apm_cli/marketplace/pr_integration.py +++ /dev/null @@ -1,498 +0,0 @@ -"""Pull request integration for marketplace publish. - -Wraps the ``gh`` CLI to open or update pull requests on consumer -repositories after the publisher has pushed update branches. - -This module is a library only -- no CLI wiring. The CLI command -(``apm marketplace publish``) is wired in a later wave. - -Design ------- -* **No pushing**: ``PrIntegrator`` only reads PR state and opens or - updates PRs. Safe-force-push coordination is the caller's - responsibility. -* **Token redaction**: stderr from ``gh`` subprocesses is redacted - via ``_git_utils.redact_token``. -* **Error isolation**: a failing ``gh`` call returns ``PrState.FAILED`` - rather than raising -- callers can continue with other targets. -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import tempfile -from collections.abc import Callable -from dataclasses import dataclass -from enum import Enum - -from ._git_utils import redact_token as _redact_token -from .git_stderr import translate_git_stderr -from .publisher import ConsumerTarget, PublishOutcome, PublishPlan, TargetResult - -__all__ = [ - "PrIntegrator", - "PrResult", - "PrState", -] - -# --------------------------------------------------------------------------- -# Token redaction -- delegated to _git_utils; alias kept for call-site compat. -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -class PrState(str, Enum): - """Outcome of a PR operation on a single consumer target.""" - - OPENED = "opened" # new PR created - UPDATED = "updated" # existing PR for the branch already open - SKIPPED = "skipped" # no update needed (non-UPDATED outcome) - FAILED = "failed" # gh call failed - DISABLED = "disabled" # --no-pr was set for this target - - -@dataclass(frozen=True) -class PrResult: - """Result of a PR operation on a single consumer target.""" - - target: ConsumerTarget - state: PrState - pr_number: int | None # set when OPENED or UPDATED - pr_url: str | None # set when OPENED or UPDATED - message: str # human-readable detail - - -# --------------------------------------------------------------------------- -# PR URL parsing -# --------------------------------------------------------------------------- - -_PR_NUMBER_RE = re.compile(r"/pull/(\d+)") - - -# --------------------------------------------------------------------------- -# Template helpers -# --------------------------------------------------------------------------- - - -def _extract_short_hash(plan: PublishPlan) -> str: - """Return the short hash from *plan*, falling back to the branch name. - - The branch name is ``apm/marketplace-update-{name}-{ver}-{hash}`` - so the hash is the last segment after the final ``-``. - """ - if plan.short_hash: - return plan.short_hash - # Derive from branch_name -- it ends with "-{short_hash}" - parts = plan.branch_name.rsplit("-", 1) - if len(parts) == 2: - return parts[1] - return "" - - -def _build_title(plan: PublishPlan) -> str: - """Build the PR title.""" - return f"chore(apm): bump {plan.marketplace_name} to {plan.marketplace_version}" - - -def _build_body(plan: PublishPlan, target: ConsumerTarget) -> str: - """Build the PR body.""" - short_hash = _extract_short_hash(plan) - return ( - f"Automated update from `apm marketplace publish`.\n" - f"\n" - f"- Marketplace: `{plan.marketplace_name}`\n" - f"- New version: `{plan.marketplace_version}`\n" - f"- New ref: `{plan.new_ref}`\n" - f"- Branch: `{plan.branch_name}`\n" - f"\n" - f"This PR updates `dependencies.apm` entries that reference " - f"`{plan.marketplace_name}` " - f"in `{target.path_in_repo}`.\n" - f"\n" - f"\n" - ) - - -# --------------------------------------------------------------------------- -# PrIntegrator service -# --------------------------------------------------------------------------- - - -class PrIntegrator: - """Open or update pull requests on consumer repositories. - - Wraps the ``gh`` CLI. All subprocess calls go through the - injectable *runner* so tests can fake them without real processes. - - Parameters - ---------- - runner: - Callable with the same signature as ``subprocess.run``. - Defaults to ``subprocess.run``. - gh_bin: - Path or name of the ``gh`` binary. Defaults to ``"gh"``. - timeout_s: - Timeout in seconds for each ``gh`` invocation. - """ - - def __init__( - self, - *, - runner: Callable[..., subprocess.CompletedProcess] | None = None, - gh_bin: str = "gh", - timeout_s: float = 30.0, - ) -> None: - self._runner = runner or subprocess.run - self._gh_bin = gh_bin - self._timeout_s = timeout_s - - # -- availability check ------------------------------------------------- - - def check_available(self) -> tuple[bool, str]: - """Return ``(True, version)`` if gh is installed and authenticated. - - Returns ``(False, hint)`` otherwise. - """ - # 1. Check gh is installed - try: - result = self._runner( - [self._gh_bin, "--version"], - capture_output=True, - text=True, - timeout=self._timeout_s, - ) - if result.returncode != 0: - return ( - False, - "gh CLI not found on PATH. Install from " - "https://cli.github.com/ or pass --no-pr.", - ) - version = result.stdout.strip() - except (OSError, FileNotFoundError): - return ( - False, - "gh CLI not found on PATH. Install from https://cli.github.com/ or pass --no-pr.", - ) - - # 2. Check gh is authenticated - try: - auth_result = self._runner( - [self._gh_bin, "auth", "status"], - capture_output=True, - text=True, - timeout=self._timeout_s, - ) - if auth_result.returncode != 0: - return ( - False, - "gh CLI is not authenticated. Run 'gh auth login' or pass --no-pr.", - ) - except (OSError, FileNotFoundError): - return ( - False, - "gh CLI is not authenticated. Run 'gh auth login' or pass --no-pr.", - ) - - return (True, version) - - # -- open or update ----------------------------------------------------- - - def open_or_update( - self, - plan: PublishPlan, - target: ConsumerTarget, - target_result: TargetResult, - *, - no_pr: bool = False, - draft: bool = False, - dry_run: bool = False, - ) -> PrResult: - """Open or update a PR on the consumer repository. - - Parameters - ---------- - plan: - The publish plan for this run. - target: - The consumer repository target. - target_result: - The result of the publish step for this target. - no_pr: - If ``True``, skip PR creation entirely. - draft: - If ``True``, create the PR as a draft. - dry_run: - If ``True``, do not actually create the PR. - - Returns - ------- - PrResult - The outcome of the PR operation. - """ - if no_pr: - return PrResult( - target=target, - state=PrState.DISABLED, - pr_number=None, - pr_url=None, - message="PR creation disabled (--no-pr).", - ) - - if target_result.outcome != PublishOutcome.UPDATED: - return PrResult( - target=target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {target_result.outcome.value}", - ) - - try: - return self._open_or_update_inner( - plan, - target, - draft=draft, - dry_run=dry_run, - ) - except subprocess.CalledProcessError as exc: - stderr = _redact_token(exc.stderr or "") - translated = translate_git_stderr( - stderr, - exit_code=exc.returncode, - operation="gh pr", - remote=target.repo, - ) - return PrResult( - target=target, - state=PrState.FAILED, - pr_number=None, - pr_url=None, - message=f"gh failed: {translated.summary} -- {stderr}", - ) - except subprocess.TimeoutExpired: - return PrResult( - target=target, - state=PrState.FAILED, - pr_number=None, - pr_url=None, - message=f"gh timed out after {self._timeout_s}s.", - ) - except OSError as exc: - return PrResult( - target=target, - state=PrState.FAILED, - pr_number=None, - pr_url=None, - message=f"OS error running gh: {exc}", - ) - - # -- internal methods --------------------------------------------------- - - def _open_or_update_inner( - self, - plan: PublishPlan, - target: ConsumerTarget, - *, - draft: bool = False, - dry_run: bool = False, - ) -> PrResult: - """Core logic for open_or_update, without error handling.""" - # 1. Check for existing PR - existing = self._find_existing_pr(plan, target) - - title = _build_title(plan) - body = _build_body(plan, target) - - if existing is not None: - # Existing PR found - pr_number = existing["number"] - pr_url = existing["url"] - existing_body = existing.get("body", "") - - if body == existing_body: - return PrResult( - target=target, - state=PrState.UPDATED, - pr_number=pr_number, - pr_url=pr_url, - message="PR already open, body unchanged.", - ) - - # Update the PR body - self._update_pr_body(target, pr_number, body) - return PrResult( - target=target, - state=PrState.UPDATED, - pr_number=pr_number, - pr_url=pr_url, - message="PR body updated.", - ) - - # 2. No existing PR -- create - if dry_run: - return PrResult( - target=target, - state=PrState.OPENED, - pr_number=None, - pr_url=None, - message="[dry-run] Would open PR.", - ) - - pr_url, pr_number = self._create_pr( - plan, - target, - title, - body, - draft=draft, - ) - - return PrResult( - target=target, - state=PrState.OPENED, - pr_number=pr_number, - pr_url=pr_url, - message="PR opened.", - ) - - def _find_existing_pr( - self, - plan: PublishPlan, - target: ConsumerTarget, - ) -> dict | None: - """Return the first open PR for *plan.branch_name*, or ``None``.""" - result = self._runner( - [ - self._gh_bin, - "pr", - "list", - "--repo", - target.repo, - "--head", - plan.branch_name, - "--state", - "open", - "--json", - "number,url,body,headRefOid", - "--limit", - "1", - ], - capture_output=True, - text=True, - timeout=self._timeout_s, - check=True, - ) - - try: - prs = json.loads(result.stdout) - except (json.JSONDecodeError, TypeError) as exc: - raise OSError(f"Failed to parse gh pr list output: {exc}") from exc - - if not prs: - return None - return prs[0] - - def _update_pr_body( - self, - target: ConsumerTarget, - pr_number: int, - body: str, - ) -> None: - """Update the body of an existing PR.""" - with tempfile.NamedTemporaryFile( - mode="w", - suffix=".md", - delete=False, - encoding="utf-8", - ) as fh: - fh.write(body) - tmp_path = fh.name - - try: - self._runner( - [ - self._gh_bin, - "pr", - "edit", - str(pr_number), - "--repo", - target.repo, - "--body-file", - tmp_path, - ], - capture_output=True, - text=True, - timeout=self._timeout_s, - check=True, - ) - finally: - try: # noqa: SIM105 - os.unlink(tmp_path) - except OSError: - pass - - def _create_pr( - self, - plan: PublishPlan, - target: ConsumerTarget, - title: str, - body: str, - *, - draft: bool = False, - ) -> tuple[str, int]: - """Create a new PR and return ``(url, number)``.""" - with tempfile.NamedTemporaryFile( - mode="w", - suffix=".md", - delete=False, - encoding="utf-8", - ) as fh: - fh.write(body) - tmp_path = fh.name - - try: - cmd = [ - self._gh_bin, - "pr", - "create", - "--repo", - target.repo, - "--base", - target.branch, - "--head", - plan.branch_name, - "--title", - title, - "--body-file", - tmp_path, - ] - if draft: - cmd.append("--draft") - - result = self._runner( - cmd, - capture_output=True, - text=True, - timeout=self._timeout_s, - check=True, - ) - finally: - try: # noqa: SIM105 - os.unlink(tmp_path) - except OSError: - pass - - # Parse the PR URL from stdout (last non-empty line) - lines = result.stdout.strip().splitlines() - pr_url = lines[-1].strip() if lines else "" - - match = _PR_NUMBER_RE.search(pr_url) - pr_number = int(match.group(1)) if match else 0 - - return pr_url, pr_number diff --git a/src/apm_cli/marketplace/publisher.py b/src/apm_cli/marketplace/publisher.py deleted file mode 100644 index 4ce48cf24..000000000 --- a/src/apm_cli/marketplace/publisher.py +++ /dev/null @@ -1,922 +0,0 @@ -"""Marketplace publisher service -- update consumer repos with new versions. - -Provides ``MarketplacePublisher`` for updating marketplace version -references in consumer repositories. The publisher reads the local -``marketplace.yml``, computes a deterministic branch name and commit -message, then clones each consumer repo, updates its ``apm.yml``, and -pushes a feature branch. - -This module is a library only -- no CLI wiring. The CLI command -(``apm marketplace publish``) is wired in a later wave. - -Design ------- -* **Byte integrity**: the publisher NEVER modifies or regenerates - ``marketplace.json`` content. It only copies the file as-is from - the marketplace source repo. -* **Token redaction**: stderr from git subprocesses is redacted via - ``_git_utils.redact_token``. -* **Atomic writes**: state files and consumer ``apm.yml`` updates use - write-tmp + ``os.fsync`` + ``os.replace``. -* **Error isolation**: failures in one target never abort other targets. -""" - -from __future__ import annotations - -import hashlib -import json -import logging -import os -import re -import subprocess -import tempfile -from collections.abc import Callable, Sequence -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass -from datetime import datetime, timezone -from enum import Enum -from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from .semver import SemVer - -import yaml - -from ..utils.path_security import ( - PathTraversalError, - ensure_path_within, - validate_path_segments, -) -from ._git_utils import redact_token as _redact_token -from ._io import atomic_write -from .errors import MarketplaceError -from .git_stderr import translate_git_stderr -from .migration import load_marketplace_config -from .ref_resolver import RefResolver -from .resolver import parse_marketplace_ref -from .semver import parse_semver -from .tag_pattern import render_tag - -logger = logging.getLogger(__name__) - -__all__ = [ - "ConsumerTarget", - "MarketplacePublisher", - "PublishOutcome", - "PublishPlan", - "PublishState", - "TargetResult", -] - -# --------------------------------------------------------------------------- -# Token redaction -- delegated to _git_utils; alias kept for call-site compat. -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# Branch name sanitisation -# --------------------------------------------------------------------------- - -_BRANCH_UNSAFE_RE = re.compile(r"[^a-zA-Z0-9._-]") - -# Pattern for safe git remote URLs (HTTPS or SSH). -_SAFE_REPO_RE = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$") - -# Shell metacharacters that must never appear in branch names or repo slugs. -_SHELL_META_RE = re.compile(r"[;&|`$(){}!<>\"\']") - - -def _sanitise_branch_segment(text: str) -> str: - """Replace characters that are unsafe for git branch names with hyphens.""" - return _BRANCH_UNSAFE_RE.sub("-", text) - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -_REPO_RE = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$") -_BRANCH_SAFE_RE = re.compile(r"^[a-zA-Z0-9._/-]+$") - - -@dataclass(frozen=True) -class ConsumerTarget: - """A consumer repository whose ``apm.yml`` should be updated.""" - - repo: str # e.g. "acme-org/service-a" - branch: str = "main" # base branch on the consumer to PR into - path_in_repo: str = "apm.yml" # location of the consumer's apm.yml - - def __post_init__(self) -> None: - if not _REPO_RE.match(self.repo): - raise ValueError( - f"ConsumerTarget.repo must be in 'owner/name' format " - f"using only alphanumerics, dots, hyphens, and underscores. " - f"Got: {self.repo!r}" - ) - if not _BRANCH_SAFE_RE.match(self.branch) or ".." in self.branch: - raise ValueError( - f"ConsumerTarget.branch contains disallowed characters. " - f"Only alphanumerics, dots, hyphens, underscores, and " - f"forward slashes are permitted (no '..' sequences). " - f"Got: {self.branch!r}" - ) - from ..utils.path_security import validate_path_segments - - validate_path_segments(self.path_in_repo, context="consumer-targets path_in_repo") - - -@dataclass(frozen=True) -class PublishPlan: - """Computed plan for a publish run -- frozen and deterministic.""" - - marketplace_name: str # name from the local marketplace.yml - marketplace_version: str # version from the local marketplace.yml - targets: tuple[ConsumerTarget, ...] - commit_message: str # pre-computed, contains the APM trailer - branch_name: str # pre-computed, deterministic - new_ref: str # rendered tag, e.g. "v2.0.0" - tag_pattern_used: str # tag pattern, e.g. "v{version}" - short_hash: str = "" # deterministic hash suffix for the branch name - allow_downgrade: bool = False - allow_ref_change: bool = False - target_package: str | None = None - - -class PublishOutcome(str, Enum): - """Outcome of processing a single consumer target.""" - - UPDATED = "updated" - NO_CHANGE = "no-change" - SKIPPED_DOWNGRADE = "skipped-downgrade" - SKIPPED_REF_CHANGE = "skipped-ref-change" - FAILED = "failed" - - -@dataclass(frozen=True) -class TargetResult: - """Result of processing a single consumer target.""" - - target: ConsumerTarget - outcome: PublishOutcome - message: str # human-readable detail - old_version: str | None = None - new_version: str | None = None - - -# --------------------------------------------------------------------------- -# Transactional state file -# --------------------------------------------------------------------------- - -_STATE_FILENAME = "publish-state.json" -_STATE_DIR = ".apm" -_MAX_HISTORY = 10 -_SCHEMA_VERSION = 1 - - -class PublishState: - """Transactional state file for publish runs. - - State is persisted at ``.apm/publish-state.json`` relative to the - marketplace repo root. All writes are atomic (write-tmp + fsync + - ``os.replace``). - """ - - def __init__(self, root: Path) -> None: - self._root = root.resolve() - self._state_dir = self._root / _STATE_DIR - self._state_path = self._state_dir / _STATE_FILENAME - self._data: dict[str, Any] = { - "schemaVersion": _SCHEMA_VERSION, - "lastRun": None, - "history": [], - } - - @classmethod - def load(cls, root: Path) -> PublishState: - """Load state from disk or return a fresh instance. - - A missing file or corrupt JSON both result in a fresh state -- - no exception is raised. - """ - instance = cls(root) - if instance._state_path.exists(): - try: - text = instance._state_path.read_text(encoding="utf-8") - data = json.loads(text) - if isinstance(data, dict): - instance._data = data - except (json.JSONDecodeError, OSError): - pass # start fresh on corrupt state - return instance - - def _atomic_write(self) -> None: - """Write state atomically via temp file + fsync + os.replace. - - Path validation and directory creation happen here; the actual - write is delegated to the shared ``atomic_write()`` helper from - ``_io.py``. - """ - ensure_path_within(self._state_dir, self._root) - self._state_dir.mkdir(parents=True, exist_ok=True) - - content = json.dumps(self._data, indent=2) + "\n" - atomic_write(self._state_path, content) - - def begin_run(self, plan: PublishPlan) -> None: - """Start a new publish run -- writes ``startedAt``.""" - self._data["lastRun"] = { - "startedAt": datetime.now(timezone.utc).isoformat(), - "finishedAt": None, - "marketplaceName": plan.marketplace_name, - "marketplaceVersion": plan.marketplace_version, - "branchName": plan.branch_name, - "results": [], - } - self._atomic_write() - - def record_result(self, result: TargetResult) -> None: - """Append a target result to the current run.""" - if self._data.get("lastRun") is None: - return - self._data["lastRun"]["results"].append( - { - "repo": result.target.repo, - "outcome": result.outcome.value, - "message": result.message, - "oldVersion": result.old_version, - "newVersion": result.new_version, - } - ) - self._atomic_write() - - def finalise(self, finished_at: datetime) -> None: - """Finalise the current run and rotate history.""" - if self._data.get("lastRun") is None: - return - self._data["lastRun"]["finishedAt"] = finished_at.isoformat() - - # Rotate history -- keep at most _MAX_HISTORY entries - history = self._data.get("history", []) - history.insert(0, dict(self._data["lastRun"])) - self._data["history"] = history[:_MAX_HISTORY] - self._atomic_write() - - def abort(self, reason: str) -> None: - """Mark the current run as aborted.""" - if self._data.get("lastRun") is None: - return - self._data["lastRun"]["finishedAt"] = f"ABORTED: {reason}" - self._atomic_write() - - @property - def data(self) -> dict[str, Any]: - """Return the raw state data (read-only snapshot for inspection).""" - return dict(self._data) - - -# --------------------------------------------------------------------------- -# Publisher service -# --------------------------------------------------------------------------- - -_GIT_TIMEOUT = 60 - - -class MarketplacePublisher: - """Update consumer repositories with new marketplace versions. - - Parameters - ---------- - marketplace_root: - Path to the marketplace repository root (must contain an - ``apm.yml`` with a ``marketplace`` block, or the legacy - ``marketplace.yml``). - ref_resolver: - Optional ``RefResolver`` instance (reserved for future use). - clock: - Callable returning the current ``datetime`` (injectable for - tests). - runner: - Callable with the same signature as ``subprocess.run`` - (injectable for tests). - """ - - def __init__( - self, - marketplace_root: Path, - *, - ref_resolver: RefResolver | None = None, - clock: Callable[[], datetime] | None = None, - runner: Callable[..., subprocess.CompletedProcess] | None = None, - ) -> None: - self._root = marketplace_root.resolve() - self._ref_resolver = ref_resolver - self._clock = clock or (lambda: datetime.now(timezone.utc)) - self._runner = runner or subprocess.run - self._yml = None - - def _load_yml(self): - """Lazy-load marketplace config (apm.yml or legacy marketplace.yml).""" - if self._yml is None: - self._yml = load_marketplace_config(self._root) - return self._yml - - # -- plan --------------------------------------------------------------- - - def plan( - self, - targets: Sequence[ConsumerTarget], - *, - target_package: str | None = None, - allow_downgrade: bool = False, - allow_ref_change: bool = False, - ) -> PublishPlan: - """Compute a publish plan. - - Reads the local marketplace config (``apm.yml`` or legacy - ``marketplace.yml``) to discover the marketplace name and version, - validates all targets, and computes a deterministic branch name - and commit message. - - Parameters - ---------- - targets: - Consumer repositories to update. - target_package: - If set, only update the reference for this specific package. - If ``None``, bump the marketplace version across all targets. - allow_downgrade: - Allow version downgrades (new < old). - allow_ref_change: - Allow switching from an explicit ref to a version range. - - Returns - ------- - PublishPlan - Frozen plan ready for ``execute()``. - - Raises - ------ - MarketplaceYmlError - If the marketplace config (``apm.yml`` or legacy - ``marketplace.yml``) cannot be loaded or is invalid. - PathTraversalError - If any target's ``path_in_repo`` is a path traversal. - """ - yml = self._load_yml() - - # Validate path_in_repo for each target - for target in targets: - validate_path_segments( - target.path_in_repo, - context=f"path_in_repo for {target.repo}", - ) - - # Validate repo and branch for each target - for target in targets: - # Repo must be a safe "owner/repo" slug with no shell metacharacters. - if _SHELL_META_RE.search(target.repo): - raise MarketplaceError( - f"Consumer target repo '{target.repo}' contains " - f"prohibited shell metacharacters." - ) - if not _SAFE_REPO_RE.match(target.repo): - raise MarketplaceError( - f"Consumer target repo '{target.repo}' must match " - f"'owner/repo' (alphanumeric, dots, hyphens, underscores)." - ) - # Branch must not contain traversal sequences or shell metacharacters. - validate_path_segments( - target.branch, - context=f"consumer target branch for {target.repo}", - ) - if _SHELL_META_RE.search(target.branch): - raise MarketplaceError( - f"Consumer target branch '{target.branch}' for " - f"'{target.repo}' contains prohibited shell metacharacters." - ) - - # Compute short hash - sorted_repos = sorted(t.repo for t in targets) - hash_input = "|".join(sorted_repos) + "|" + yml.version - if target_package: - hash_input += "|" + target_package - short_hash = hashlib.sha1(hash_input.encode("utf-8")).hexdigest()[:8] # noqa: S324 - - # Compute branch name - name_segment = _sanitise_branch_segment(yml.name) - version_segment = _sanitise_branch_segment(yml.version) - branch_name = f"apm/marketplace-update-{name_segment}-{version_segment}-{short_hash}" - - # Compute commit message - commit_message = ( - f"chore(apm): bump {yml.name} to {yml.version}\n" - f"\n" - f"Updated by apm marketplace publish.\n" - f"\n" - f"APM-Publish-Id: {short_hash}" - ) - - # Compute tag for the new version - tag_pattern = yml.build.tag_pattern - new_ref = render_tag(tag_pattern, name=yml.name, version=yml.version) - - return PublishPlan( - marketplace_name=yml.name, - marketplace_version=yml.version, - targets=tuple(targets), - commit_message=commit_message, - branch_name=branch_name, - new_ref=new_ref, - tag_pattern_used=tag_pattern, - short_hash=short_hash, - allow_downgrade=allow_downgrade, - allow_ref_change=allow_ref_change, - target_package=target_package, - ) - - # -- execute ------------------------------------------------------------ - - def execute( - self, - plan: PublishPlan, - *, - dry_run: bool = False, - parallel: int = 4, - ) -> list[TargetResult]: - """Execute a publish plan. - - Iterates targets in parallel, updating each consumer's - ``apm.yml`` with the new marketplace version. - - Parameters - ---------- - plan: - Plan computed by ``plan()``. - dry_run: - If ``True``, do not push changes to remote. - parallel: - Maximum number of concurrent target updates. - - Returns - ------- - list[TargetResult] - Results in the same order as ``plan.targets``. - """ - state = PublishState.load(self._root) - state.begin_run(plan) - - results: dict[int, TargetResult] = {} - - def _process(idx: int, target: ConsumerTarget) -> TargetResult: - try: - return self._process_single_target(target, plan, dry_run=dry_run) - except Exception as exc: - logger.debug("Target processing failed for %s", target.repo, exc_info=True) - return TargetResult( - outcome=PublishOutcome.FAILED, - message=_redact_token(str(exc)), - ) - - workers = max(1, min(parallel, len(plan.targets))) - - with ThreadPoolExecutor(max_workers=workers) as pool: - future_to_idx = { - pool.submit(_process, idx, target): idx for idx, target in enumerate(plan.targets) - } - for future in as_completed(future_to_idx): - idx = future_to_idx[future] - try: - result = future.result() - except Exception as exc: - logger.debug("Future result failed for target %d", idx, exc_info=True) - result = TargetResult( - target=plan.targets[idx], - outcome=PublishOutcome.FAILED, - message=_redact_token(str(exc)), - ) - results[idx] = result - state.record_result(result) - - state.finalise(self._clock()) - - # Return in plan.targets order - return [results[i] for i in range(len(plan.targets))] - - # -- per-target helpers ------------------------------------------------- - - def _load_consumer_manifest( - self, - clone_dir: Path, - target: ConsumerTarget, - plan: PublishPlan, - ) -> tuple[dict | None, Path, TargetResult | None]: - """Load and validate consumer apm.yml. - - Returns ``(data, apm_yml_path, None)`` on success or - ``(None, apm_yml_path, TargetResult)`` on first error. - """ - apm_yml_path = clone_dir / target.path_in_repo - try: - ensure_path_within(apm_yml_path, clone_dir) - except PathTraversalError: - return ( - None, - apm_yml_path, - TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message="Path traversal rejected: " + target.path_in_repo, - ), - ) - - if not apm_yml_path.exists(): - return ( - None, - apm_yml_path, - TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=f"File not found: {target.path_in_repo}", - ), - ) - - try: - raw_text = apm_yml_path.read_text(encoding="utf-8") - data = yaml.safe_load(raw_text) - except (yaml.YAMLError, OSError) as exc: - return ( - None, - apm_yml_path, - TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=f"Failed to parse {target.path_in_repo}: {exc}", - ), - ) - - if not isinstance(data, dict): - return ( - None, - apm_yml_path, - TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message="Invalid apm.yml: expected a mapping", - ), - ) - - deps = data.get("dependencies") - if not isinstance(deps, dict): - return ( - None, - apm_yml_path, - TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=f"Marketplace '{plan.marketplace_name}' not referenced in apm.yml", - ), - ) - - apm_deps = deps.get("apm") - if not isinstance(apm_deps, list): - return ( - None, - apm_yml_path, - TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=f"Marketplace '{plan.marketplace_name}' not referenced in apm.yml", - ), - ) - - return data, apm_yml_path, None - - def _check_ref_guards( - self, - matches: list[tuple[int, str, str | None, str]], - target: ConsumerTarget, - plan: PublishPlan, - new_sv: SemVer | None, - ) -> TargetResult | None: - """Check ref-change and downgrade guards. Returns error result or None.""" - new_ref = plan.new_ref - for _idx, _pname, old_ref, entry_str in matches: - if old_ref == new_ref: - continue - - # Ref-change guard - if old_ref is None: - if not plan.allow_ref_change: - return TargetResult( - target=target, - outcome=PublishOutcome.SKIPPED_REF_CHANGE, - message=( - f"Entry '{entry_str}' uses implicit " - "latest; pass allow_ref_change to pin" - ), - old_version=None, - new_version=new_ref, - ) - else: - old_sv = parse_semver(old_ref.lstrip("vV")) - if old_sv is None and new_sv is not None: - if not plan.allow_ref_change: - return TargetResult( - target=target, - outcome=PublishOutcome.SKIPPED_REF_CHANGE, - message=( - f"Entry '{entry_str}' uses " - f"non-semver ref '{old_ref}'; " - "pass allow_ref_change to switch" - ), - old_version=old_ref, - new_version=new_ref, - ) - - # Downgrade guard - if old_sv and new_sv and new_sv < old_sv: - if not plan.allow_downgrade: - return TargetResult( - target=target, - outcome=PublishOutcome.SKIPPED_DOWNGRADE, - message=( - f"Downgrade from {old_ref} to " - f"{new_ref}; pass allow_downgrade " - "to override" - ), - old_version=old_ref, - new_version=new_ref, - ) - return None - - # -- per-target processing ---------------------------------------------- - - def _process_single_target( - self, - target: ConsumerTarget, - plan: PublishPlan, - *, - dry_run: bool = False, - ) -> TargetResult: - """Clone, update, commit, and optionally push a single target.""" - with tempfile.TemporaryDirectory(prefix="apm-publish-") as tmpdir: - clone_dir = Path(tmpdir) / "repo" - - # 1. Shallow clone - url = f"https://github.com/{target.repo}.git" - try: - self._run_git( - [ - "git", - "clone", - "--depth=1", - "--branch", - target.branch, - url, - str(clone_dir), - ], - cwd=tmpdir, - ) - except subprocess.CalledProcessError as exc: - stderr = _redact_token(exc.stderr or "") - translated = translate_git_stderr( - stderr, - exit_code=exc.returncode, - operation="clone", - remote=target.repo, - ) - return TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=f"Clone failed: {translated.summary}", - ) - - # 2. Create publish branch - try: - self._run_git( - ["git", "checkout", "-B", plan.branch_name], - cwd=str(clone_dir), - ) - except subprocess.CalledProcessError as exc: - return TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=("Branch creation failed: " + _redact_token(str(exc))), - ) - - # 3. Load consumer apm.yml - data, apm_yml_path, manifest_err = self._load_consumer_manifest(clone_dir, target, plan) - if manifest_err is not None: - return manifest_err - - # 4. Find matching marketplace entries in dependencies.apm - apm_deps = data["dependencies"]["apm"] - - # Parse each entry with parse_marketplace_ref - new_ref = plan.new_ref - mkt_lower = plan.marketplace_name.lower() - matches: list[tuple[int, str, str | None, str]] = [] - warnings: list[str] = [] - - for idx, entry_str in enumerate(apm_deps): - if not isinstance(entry_str, str): - continue - try: - parsed = parse_marketplace_ref(entry_str) - except ValueError as exc: - warnings.append(str(exc)) - continue - if parsed is None: - continue # Direct repo ref -- not a marketplace entry - _plugin_name, entry_mkt, old_ref = parsed - if entry_mkt.lower() == mkt_lower: - matches.append((idx, _plugin_name, old_ref, entry_str)) - - # 5. Zero matches -> FAILED - if not matches: - warn_suffix = "" - if warnings: - warn_suffix = " (warnings: " + "; ".join(warnings) + ")" - return TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=( - f"Marketplace '{plan.marketplace_name}' not " - f"referenced in apm.yml{warn_suffix}" - ), - ) - - # 6. Guards -- check every entry that would change - new_sv = parse_semver(new_ref.lstrip("vV")) - guard_err = self._check_ref_guards(matches, target, plan, new_sv) - if guard_err is not None: - return guard_err - - # 7. No-change check - needs_update = any(old_ref != new_ref for _, _, old_ref, _ in matches) - if not needs_update: - return TargetResult( - target=target, - outcome=PublishOutcome.NO_CHANGE, - message=f"Already at {new_ref}", - old_version=new_ref, - new_version=new_ref, - ) - - # 8. Apply updates to matching entries - first_old_ref: str | None = None - updated_count = 0 - for idx, _pname, old_ref, entry_str in matches: - if old_ref == new_ref: - continue - if first_old_ref is None: - first_old_ref = old_ref - if "#" in entry_str: - base = entry_str.split("#", 1)[0] - apm_deps[idx] = f"{base}#{new_ref}" - else: - apm_deps[idx] = f"{entry_str}#{new_ref}" - updated_count += 1 - - # 9. Write apm.yml atomically - new_text = yaml.safe_dump( - data, default_flow_style=False, sort_keys=False - ) # yaml-io-exempt - tmp_yml = apm_yml_path.with_suffix(".yml.tmp") - try: - with open(tmp_yml, "w", encoding="utf-8") as fh: - fh.write(new_text) - fh.flush() - os.fsync(fh.fileno()) - os.replace(str(tmp_yml), str(apm_yml_path)) - except BaseException: - try: # noqa: SIM105 - tmp_yml.unlink(missing_ok=True) - except OSError: - pass - raise - - # 10. Git add + commit - try: - self._run_git( - ["git", "add", target.path_in_repo], - cwd=str(clone_dir), - ) - msg_file = Path(tmpdir) / "commit-msg.txt" - msg_file.write_text(plan.commit_message, encoding="utf-8") - self._run_git( - ["git", "commit", "-F", str(msg_file)], - cwd=str(clone_dir), - ) - except subprocess.CalledProcessError as exc: - return TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=("Commit failed: " + _redact_token(str(exc))), - ) - - # 11. Git push (unless dry_run) - if not dry_run: - try: - self._run_git( - [ - "git", - "push", - "-u", - "origin", - plan.branch_name, - ], - cwd=str(clone_dir), - ) - except subprocess.CalledProcessError as exc: - stderr = _redact_token(exc.stderr or "") - return TargetResult( - target=target, - outcome=PublishOutcome.FAILED, - message=f"Push failed: {stderr}", - ) - - old_label = first_old_ref or "unset" - if updated_count == 1: - msg = f"Updated {plan.marketplace_name} from {old_label} to {new_ref}" - else: - msg = f"Updated {updated_count} entries for {plan.marketplace_name} to {new_ref}" - return TargetResult( - target=target, - outcome=PublishOutcome.UPDATED, - message=msg, - old_version=first_old_ref, - new_version=new_ref, - ) - - # -- git runner --------------------------------------------------------- - - def _run_git( - self, - cmd: list[str], - *, - cwd: str | None = None, - timeout: int = _GIT_TIMEOUT, - ) -> subprocess.CompletedProcess: - """Run a git command via the injectable runner.""" - env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} - return self._runner( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=timeout, - check=True, - env=env, - ) - - # -- safe force push ---------------------------------------------------- - - def safe_force_push( - self, - remote: str, - branch_name: str, - expected_trailer: str, - ) -> bool: - """Force-push only if the remote branch head has the expected trailer. - - Checks that the remote branch's HEAD commit message contains - ``APM-Publish-Id: ``. If it does, performs - a ``git push --force-with-lease``; otherwise refuses silently. - - Returns ``True`` on push success, ``False`` if refused or on - any error. Never raises for the trailer-mismatch case. - """ - try: - result = self._run_git( - [ - "git", - "log", - "--format=%B", - "-1", - f"{remote}/{branch_name}", - ], - cwd=str(self._root), - ) - commit_msg = result.stdout.strip() - - trailer_line = f"APM-Publish-Id: {expected_trailer}" - if trailer_line not in commit_msg: - return False - - self._run_git( - [ - "git", - "push", - "--force-with-lease", - remote, - branch_name, - ], - cwd=str(self._root), - ) - return True - except subprocess.CalledProcessError: - return False diff --git a/src/apm_cli/marketplace/validator.py b/src/apm_cli/marketplace/validator.py index 6fb353d11..41816d75d 100644 --- a/src/apm_cli/marketplace/validator.py +++ b/src/apm_cli/marketplace/validator.py @@ -1,7 +1,7 @@ """Marketplace manifest validation. Provides validation functions for marketplace.json integrity checking. -Used by ``apm marketplace validate`` and potentially by ``apm marketplace publish``. +Used by ``apm marketplace validate``. All validators operate on parsed ``MarketplaceManifest`` / ``MarketplacePlugin`` objects. The JSON parser (``models.py``) already drops entries that are diff --git a/src/apm_cli/marketplace/version_check.py b/src/apm_cli/marketplace/version_check.py index d8fb71989..fc38fd828 100644 --- a/src/apm_cli/marketplace/version_check.py +++ b/src/apm_cli/marketplace/version_check.py @@ -5,7 +5,7 @@ ``marketplace.versioning.strategy``. No git, no network. Returns a :class:`VersionAlignmentReport` that both ``pack`` and -``marketplace doctor`` consume. +``apm doctor`` consume. See ``.apm/skills/wave-4-design.md`` section 4.2 for the algorithm. """ diff --git a/src/apm_cli/marketplace/yml_schema.py b/src/apm_cli/marketplace/yml_schema.py index 0062b48f4..76781d06b 100644 --- a/src/apm_cli/marketplace/yml_schema.py +++ b/src/apm_cli/marketplace/yml_schema.py @@ -1,6 +1,6 @@ """Dataclasses, loader, and validation for marketplace authoring config. -The marketplace publisher configuration may live in two places: +The marketplace authoring configuration may live in two places: * (Preferred, current) inside ``apm.yml`` under a top-level ``marketplace:`` block. Loaded via diff --git a/tests/integration/marketplace/test_publish_integration.py b/tests/integration/marketplace/test_publish_integration.py deleted file mode 100644 index 42fa36e74..000000000 --- a/tests/integration/marketplace/test_publish_integration.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Integration tests for ``apm marketplace publish``. - -Strategy --------- -These tests use CliRunner with both ``MarketplacePublisher`` and -``PrIntegrator`` mocked out. This verifies the CLI orchestration -layer (pre-flight checks, plan rendering, confirmation guard, summary) -without touching the network or any real git repositories. - -All tests in this file use CliRunner for consistency. - -Scenarios covered: -- Happy path: publisher.plan -> publisher.execute -> PrIntegrator.open_or_update -> exit 0. -- Non-interactive without --yes exits 1. -- --dry-run is forwarded to both services (dry_run=True). -- Mixed results (one FAILED) exits 1. -- Missing marketplace.yml exits 1. -- Missing marketplace.json exits 1. -- Missing consumer-targets.yml exits 1. -- Targets file with invalid format exits 1. -- --no-pr skips PR creation (PrIntegrator not called). -""" - -from __future__ import annotations - -import json # noqa: F401 -import os -from pathlib import Path -from unittest.mock import MagicMock, call, patch # noqa: F401 - -import pytest # noqa: F401 -from click.testing import CliRunner - -from apm_cli.commands.marketplace import publish -from apm_cli.marketplace.pr_integration import PrResult, PrState -from apm_cli.marketplace.publisher import ( - ConsumerTarget, - PublishOutcome, - PublishPlan, - TargetResult, -) - -# --------------------------------------------------------------------------- -# Fixtures / helpers -# --------------------------------------------------------------------------- - -_PUBLISH_YML = """\ -name: acme-marketplace -description: Acme marketplace -version: 2.0.0 -owner: - name: Acme Corp -packages: - - name: tool-a - source: org/tool-a - version: "^1.0.0" - tags: - - test -""" - -_GOLDEN_JSON = """\ -{ - "name": "acme-marketplace", - "description": "Acme marketplace", - "version": "2.0.0", - "owner": {"name": "Acme Corp"}, - "plugins": [ - { - "name": "tool-a", - "tags": ["test"], - "source": { - "type": "github", - "repository": "org/tool-a", - "ref": "v1.2.0", - "commit": "aaaa000000000000000000000000000000000001" - } - } - ] -} -""" - -_TARGETS_YML = """\ -targets: - - repo: consumer-org/service-a - branch: main - - repo: consumer-org/service-b - branch: develop -""" - -_TARGETS_SINGLE_YML = """\ -targets: - - repo: consumer-org/service-a - branch: main -""" - - -def _make_plan(targets): - return PublishPlan( - marketplace_name="acme-marketplace", - marketplace_version="2.0.0", - targets=tuple(targets), - commit_message="chore(apm): bump acme-marketplace to 2.0.0", - branch_name="apm/marketplace-update-acme-marketplace-2.0.0-abc12345", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - short_hash="abc12345", - ) - - -def _make_target_result(repo, outcome=PublishOutcome.UPDATED): - target = ConsumerTarget(repo=repo, branch="main") - return TargetResult( - target=target, - outcome=outcome, - message=f"{repo}: {outcome.value}", - old_version="1.0.0", - new_version="2.0.0", - ) - - -def _make_pr_result(repo, state=PrState.OPENED): - target = ConsumerTarget(repo=repo, branch="main") - return PrResult( - target=target, - state=state, - pr_number=42, - pr_url=f"https://github.com/{repo}/pull/42", - message=f"PR {state.value}", - ) - - -def _setup_workspace(tmp_path: Path, with_targets=True, with_json=True): - """Write marketplace.yml, optionally marketplace.json and consumer-targets.yml.""" - (tmp_path / "marketplace.yml").write_text(_PUBLISH_YML, encoding="utf-8") - if with_json: - (tmp_path / "marketplace.json").write_text(_GOLDEN_JSON, encoding="utf-8") - if with_targets: - (tmp_path / "consumer-targets.yml").write_text(_TARGETS_SINGLE_YML, encoding="utf-8") - - -def _run_publish( - tmp_path: Path, - extra_args=(), - mock_plan=None, - mock_results=None, - mock_pr_available=True, - mock_pr_results=None, - env_overrides=None, -): - """Run publish via CliRunner with publisher and PrIntegrator mocked.""" - runner = CliRunner() - - targets = [ConsumerTarget(repo="consumer-org/service-a", branch="main")] - plan = mock_plan or _make_plan(targets) - - results = mock_results or [ - _make_target_result("consumer-org/service-a", PublishOutcome.UPDATED), - ] - pr_results = mock_pr_results or [ - _make_pr_result("consumer-org/service-a", PrState.OPENED), - ] - - env = {} - if env_overrides: - env.update(env_overrides) - - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - for fname in ("marketplace.yml", "marketplace.json", "consumer-targets.yml"): - src = tmp_path / fname - if src.exists(): - shutil.copy(str(src), f"{cwd}/{fname}") - - with ( - patch( - "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", - return_value=plan, - ), - patch( - "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", - return_value=results, - ), - patch( - "apm_cli.commands.marketplace.publish.PrIntegrator.check_available", - return_value=(mock_pr_available, "gh available"), - ), - patch( - "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", - side_effect=pr_results, - ), - patch( - "apm_cli.commands.marketplace.publish._is_interactive", - return_value=False, - ), - patch.dict(os.environ, env, clear=False), - ): - result = runner.invoke(publish, list(extra_args), catch_exceptions=False) - - return result - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestPublishHappyPath: - """Happy path: all targets updated, PRs opened, exit 0.""" - - def test_exit_code_zero_happy_path(self, tmp_path: Path): - _setup_workspace(tmp_path) - result = _run_publish(tmp_path, extra_args=["--yes"]) - assert result.exit_code == 0 - - def test_summary_appears_in_output(self, tmp_path: Path): - _setup_workspace(tmp_path) - result = _run_publish(tmp_path, extra_args=["--yes"]) - combined = result.output - # Summary table must mention the target - assert "service-a" in combined or "consumer-org" in combined - - def test_no_traceback(self, tmp_path: Path): - _setup_workspace(tmp_path) - result = _run_publish(tmp_path, extra_args=["--yes"]) - assert "Traceback" not in result.output - - -class TestPublishNonInteractive: - """Without --yes in non-interactive mode, publish exits 1.""" - - def test_exits_1_without_yes(self, tmp_path: Path): - _setup_workspace(tmp_path) - result = _run_publish(tmp_path, extra_args=[]) - assert result.exit_code == 1 - - def test_error_message_mentions_yes(self, tmp_path: Path): - _setup_workspace(tmp_path) - result = _run_publish(tmp_path, extra_args=[]) - combined = result.output - assert "--yes" in combined or "non-interactive" in combined.lower() - - -class TestPublishDryRun: - """--dry-run must be forwarded to execute (dry_run=True).""" - - def test_dry_run_forwarded_to_execute(self, tmp_path: Path): - _setup_workspace(tmp_path) - execute_mock = MagicMock( - return_value=[ - _make_target_result("consumer-org/service-a", PublishOutcome.UPDATED), - ] - ) - runner = CliRunner() - targets = [ConsumerTarget(repo="consumer-org/service-a", branch="main")] - plan = _make_plan(targets) - - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - for fname in ("marketplace.yml", "marketplace.json", "consumer-targets.yml"): - src = tmp_path / fname - if src.exists(): - shutil.copy(str(src), f"{cwd}/{fname}") - - with ( - patch( - "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", - return_value=plan, - ), - patch( - "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", - execute_mock, - ), - patch( - "apm_cli.commands.marketplace.publish.PrIntegrator.check_available", - return_value=(True, "ok"), - ), - patch( - "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", - return_value=_make_pr_result("consumer-org/service-a", PrState.SKIPPED), - ), - patch( - "apm_cli.commands.marketplace.publish._is_interactive", - return_value=False, - ), - ): - result = runner.invoke(publish, ["--yes", "--dry-run"], catch_exceptions=False) # noqa: F841 - - # execute must have been called with dry_run=True - assert execute_mock.called - call_kwargs = execute_mock.call_args - # dry_run is a keyword arg - assert call_kwargs.kwargs.get("dry_run") is True or ( - call_kwargs.args and call_kwargs.args[1] is True - ) - - def test_dry_run_message_in_output(self, tmp_path: Path): - _setup_workspace(tmp_path) - result = _run_publish(tmp_path, extra_args=["--yes", "--dry-run"]) - assert "Dry run" in result.output or "dry" in result.output.lower() - - -class TestPublishMixedResults: - """A FAILED result must cause exit 1.""" - - def test_exit_code_one_on_failure(self, tmp_path: Path): - _setup_workspace(tmp_path) - results = [ - _make_target_result("consumer-org/service-a", PublishOutcome.FAILED), - ] - result = _run_publish(tmp_path, extra_args=["--yes"], mock_results=results) - assert result.exit_code == 1 - - -class TestPublishPreflightErrors: - """Pre-flight error handling exits 1 with clear messages.""" - - def test_missing_yml_exits_1(self, tmp_path: Path): - # Only write marketplace.json and targets; no marketplace.yml - (tmp_path / "marketplace.json").write_text(_GOLDEN_JSON, encoding="utf-8") - (tmp_path / "consumer-targets.yml").write_text(_TARGETS_SINGLE_YML, encoding="utf-8") - runner = CliRunner() - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - shutil.copy(str(tmp_path / "marketplace.json"), f"{cwd}/marketplace.json") - shutil.copy(str(tmp_path / "consumer-targets.yml"), f"{cwd}/consumer-targets.yml") - result = runner.invoke(publish, ["--yes"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_missing_json_exits_1(self, tmp_path: Path): - # marketplace.yml present, but marketplace.json absent - (tmp_path / "marketplace.yml").write_text(_PUBLISH_YML, encoding="utf-8") - (tmp_path / "consumer-targets.yml").write_text(_TARGETS_SINGLE_YML, encoding="utf-8") - runner = CliRunner() - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - shutil.copy(str(tmp_path / "marketplace.yml"), f"{cwd}/marketplace.yml") - shutil.copy(str(tmp_path / "consumer-targets.yml"), f"{cwd}/consumer-targets.yml") - result = runner.invoke(publish, ["--yes"], catch_exceptions=False) - assert result.exit_code == 1 - assert "marketplace.json" in result.output - - def test_missing_targets_exits_1(self, tmp_path: Path): - # Both yml and json present but no consumer-targets.yml - _setup_workspace(tmp_path, with_targets=False) - runner = CliRunner() - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - shutil.copy(str(tmp_path / "marketplace.yml"), f"{cwd}/marketplace.yml") - shutil.copy(str(tmp_path / "marketplace.json"), f"{cwd}/marketplace.json") - result = runner.invoke(publish, ["--yes"], catch_exceptions=False) - assert result.exit_code == 1 - assert "consumer-targets.yml" in result.output or "targets" in result.output.lower() - - def test_invalid_targets_format_exits_1(self, tmp_path: Path): - """A targets file without a 'targets' key must exit 1.""" - _setup_workspace(tmp_path, with_targets=False) - (tmp_path / "consumer-targets.yml").write_text( - "not_a_targets_file: true\n", encoding="utf-8" - ) - runner = CliRunner() - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - for fname in ("marketplace.yml", "marketplace.json", "consumer-targets.yml"): - src = tmp_path / fname - if src.exists(): - shutil.copy(str(src), f"{cwd}/{fname}") - result = runner.invoke(publish, ["--yes"], catch_exceptions=False) - assert result.exit_code == 1 - - -class TestPublishNoPr: - """--no-pr skips PR creation.""" - - def test_no_pr_skips_pr_integrator(self, tmp_path: Path): - _setup_workspace(tmp_path) - open_or_update_mock = MagicMock() - runner = CliRunner() - targets = [ConsumerTarget(repo="consumer-org/service-a", branch="main")] - plan = _make_plan(targets) - - with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: - import shutil - - for fname in ("marketplace.yml", "marketplace.json", "consumer-targets.yml"): - src = tmp_path / fname - if src.exists(): - shutil.copy(str(src), f"{cwd}/{fname}") - - with ( - patch( - "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", - return_value=plan, - ), - patch( - "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", - return_value=[ - _make_target_result("consumer-org/service-a", PublishOutcome.UPDATED) - ], - ), - patch( - "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", - open_or_update_mock, - ), - patch( - "apm_cli.commands.marketplace.publish._is_interactive", - return_value=False, - ), - ): - result = runner.invoke(publish, ["--yes", "--no-pr"], catch_exceptions=False) - - # PrIntegrator.open_or_update must NOT have been called - open_or_update_mock.assert_not_called() - assert result.exit_code == 0 diff --git a/tests/integration/test_commands_mcp_flow.py b/tests/integration/test_commands_mcp_flow.py index c802ef8cd..fac32f27c 100644 --- a/tests/integration/test_commands_mcp_flow.py +++ b/tests/integration/test_commands_mcp_flow.py @@ -1334,105 +1334,6 @@ def test_generic_unsupported_host_message(self) -> None: assert "GITHUB_HOST" in msg -class TestLoadTargetsFile: - """_load_targets_file — YAML parsing.""" - - def test_valid_targets_file(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - repo: owner/svc-a - branch: main - - repo: owner/svc-b - branch: develop - path_in_repo: config/apm.yml -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert err is None - assert len(targets) == 2 - assert targets[0].repo == "owner/svc-a" - assert targets[1].path_in_repo == "config/apm.yml" - - def test_missing_targets_key_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - f = tmp_path / "targets.yml" - f.write_text("other: value\n", encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert "targets" in err.lower() or err - - def test_empty_targets_list_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - f = tmp_path / "targets.yml" - f.write_text("targets: []\n", encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert err - - def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - f = tmp_path / "targets.yml" - f.write_text("targets: [\ninvalid:\n", encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert err - - def test_repo_missing_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - branch: main -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert "'repo'" in err or "repo" in err - - def test_invalid_repo_format_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - repo: not-a-slash-format - branch: main -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert "owner/name" in err or "OWNER" in err.upper() or err - - def test_path_traversal_in_path_in_repo(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - repo: owner/repo - branch: main - path_in_repo: "../../etc/passwd" -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert err - - class TestMarketplaceListCommand: """marketplace list subcommand.""" @@ -1675,33 +1576,6 @@ def test_search_marketplace_not_registered(self) -> None: assert result.exit_code == 1 -class TestOutcomeSymbol: - """_outcome_symbol mapping.""" - - def test_updated_maps_to_plus(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - from apm_cli.marketplace.publisher import PublishOutcome - - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed_maps_to_x(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - from apm_cli.marketplace.publisher import PublishOutcome - - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_maps_to_exclamation(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - from apm_cli.marketplace.publisher import PublishOutcome - - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - -# =========================================================================== -# PART 5 — install/validation.py -# =========================================================================== - - class TestIsTlsFailure: """_is_tls_failure — chain inspection.""" diff --git a/tests/integration/test_commands_mcp_phase3c.py b/tests/integration/test_commands_mcp_phase3c.py index c802ef8cd..fac32f27c 100644 --- a/tests/integration/test_commands_mcp_phase3c.py +++ b/tests/integration/test_commands_mcp_phase3c.py @@ -1334,105 +1334,6 @@ def test_generic_unsupported_host_message(self) -> None: assert "GITHUB_HOST" in msg -class TestLoadTargetsFile: - """_load_targets_file — YAML parsing.""" - - def test_valid_targets_file(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - repo: owner/svc-a - branch: main - - repo: owner/svc-b - branch: develop - path_in_repo: config/apm.yml -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert err is None - assert len(targets) == 2 - assert targets[0].repo == "owner/svc-a" - assert targets[1].path_in_repo == "config/apm.yml" - - def test_missing_targets_key_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - f = tmp_path / "targets.yml" - f.write_text("other: value\n", encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert "targets" in err.lower() or err - - def test_empty_targets_list_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - f = tmp_path / "targets.yml" - f.write_text("targets: []\n", encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert err - - def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - f = tmp_path / "targets.yml" - f.write_text("targets: [\ninvalid:\n", encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert err - - def test_repo_missing_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - branch: main -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert "'repo'" in err or "repo" in err - - def test_invalid_repo_format_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - repo: not-a-slash-format - branch: main -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert "owner/name" in err or "OWNER" in err.upper() or err - - def test_path_traversal_in_path_in_repo(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = """\ -targets: - - repo: owner/repo - branch: main - path_in_repo: "../../etc/passwd" -""" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - - targets, err = _load_targets_file(f) - assert targets is None - assert err - - class TestMarketplaceListCommand: """marketplace list subcommand.""" @@ -1675,33 +1576,6 @@ def test_search_marketplace_not_registered(self) -> None: assert result.exit_code == 1 -class TestOutcomeSymbol: - """_outcome_symbol mapping.""" - - def test_updated_maps_to_plus(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - from apm_cli.marketplace.publisher import PublishOutcome - - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed_maps_to_x(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - from apm_cli.marketplace.publisher import PublishOutcome - - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_maps_to_exclamation(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - from apm_cli.marketplace.publisher import PublishOutcome - - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - -# =========================================================================== -# PART 5 — install/validation.py -# =========================================================================== - - class TestIsTlsFailure: """_is_tls_failure — chain inspection.""" diff --git a/tests/integration/test_deep_coverage_adapters.py b/tests/integration/test_deep_coverage_adapters.py index 6017eb247..9176716ff 100644 --- a/tests/integration/test_deep_coverage_adapters.py +++ b/tests/integration/test_deep_coverage_adapters.py @@ -7,7 +7,6 @@ - src/apm_cli/integration/hook_integrator.py (263 miss, 45%) - src/apm_cli/adapters/client/codex.py (189 miss, 10%) - src/apm_cli/output/formatters.py (215 miss, 55%) - - src/apm_cli/marketplace/publisher.py (217 miss, 25%) CRITICAL: We exercise REAL adapters and integrators with realistic file structures, NOT mocked ones. Only I/O with external systems (HTTP, subprocess) diff --git a/tests/integration/test_marketplace_e2e_surface.py b/tests/integration/test_marketplace_e2e_surface.py index a776f40d1..aa9a90b50 100644 --- a/tests/integration/test_marketplace_e2e_surface.py +++ b/tests/integration/test_marketplace_e2e_surface.py @@ -21,7 +21,6 @@ _load_config_or_exit, _load_current_versions, _load_yml_or_exit, - _outcome_symbol, _OutdatedRow, _parse_marketplace_repo, _render_build_error, @@ -29,9 +28,6 @@ _render_check_table, _render_doctor_table, _render_outdated_table, - _render_publish_footer, - _render_publish_plan, - _render_publish_summary, _warn_duplicate_names, marketplace, search, @@ -45,7 +41,6 @@ OfflineMissError, RefNotFoundError, ) -from apm_cli.marketplace.publisher import ConsumerTarget, PublishOutcome, TargetResult # --------------------------------------------------------------------------- # MarketplaceGroup.format_commands -- lines 106-121 @@ -544,145 +539,10 @@ def test_with_console_rich_table(self): # --------------------------------------------------------------------------- -# _outcome_symbol -- lines 1243-1256 # --------------------------------------------------------------------------- -class TestOutcomeSymbol: - def test_updated_symbol(self): - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed_symbol(self): - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_downgrade_symbol(self): - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - def test_no_change_symbol(self): - assert _outcome_symbol(PublishOutcome.NO_CHANGE) == "[*]" - - -# --------------------------------------------------------------------------- -# _render_publish_footer -- lines 1259-1271 -# --------------------------------------------------------------------------- - - -class TestRenderPublishFooter: - def test_all_success_calls_logger_success(self): - logger = MagicMock() - _render_publish_footer(logger, updated=3, failed=0, total=3, dry_run=False) - logger.success.assert_called() - assert "3/3" in str(logger.success.call_args) - - def test_failures_calls_logger_warning(self): - logger = MagicMock() - _render_publish_footer(logger, updated=2, failed=1, total=3, dry_run=False) - logger.warning.assert_called() - assert "failed" in str(logger.warning.call_args) - - def test_dry_run_suffix_added(self): - logger = MagicMock() - _render_publish_footer(logger, updated=1, failed=0, total=1, dry_run=True) - call_str = str(logger.success.call_args) - assert "dry-run" in call_str - - -# --------------------------------------------------------------------------- -# _render_publish_plan -- lines 1120-1168 -# --------------------------------------------------------------------------- - - -class TestRenderPublishPlan: - def _make_plan(self, target_repos: list[str]): - plan = MagicMock() - plan.marketplace_name = "my-market" - plan.marketplace_version = "1.0.0" - plan.new_ref = "v1.0.0" - plan.branch_name = "release/v1.0.0" - targets = [] - for repo in target_repos: - t = MagicMock() - t.repo = repo - t.branch = "main" - t.path_in_repo = "apm.yml" - targets.append(t) - plan.targets = targets - return plan - - def test_no_console_colorama_fallback(self): - logger = MagicMock() - plan = self._make_plan(["org/repo-a", "org/repo-b"]) - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_plan(logger, plan) - logger.progress.assert_called() - assert logger.tree_item.call_count >= 2 - - def test_with_console_rich_panel(self): - logger = MagicMock() - plan = self._make_plan(["org/repo-a"]) - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_plan(logger, plan) - mock_console.print.assert_called() - - -# --------------------------------------------------------------------------- -# _render_publish_summary -- lines 1171-1240 -# --------------------------------------------------------------------------- - - -class TestRenderPublishSummary: - def _make_result(self, repo: str, outcome: PublishOutcome, message: str = "ok"): - r = MagicMock(spec=TargetResult) - t = MagicMock(spec=ConsumerTarget) - t.repo = repo - r.target = t - r.outcome = outcome - r.message = message - return r - - def _make_pr_result(self, repo: str, state_value: str = "open", pr_number: int = 42): - pr = MagicMock() - t = MagicMock() - t.repo = repo - pr.target = t - pr.state = MagicMock() - pr.state.value = state_value - pr.pr_number = pr_number - pr.pr_url = f"https://github.com/{repo}/pull/{pr_number}" - return pr - - def test_no_console_colorama_fallback_no_pr(self): - logger = MagicMock() - results = [ - self._make_result("org/repo-a", PublishOutcome.UPDATED), - self._make_result("org/repo-b", PublishOutcome.FAILED), - ] - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_summary(logger, results, [], no_pr=True, dry_run=False) - logger.tree_item.assert_called() - - def test_with_console_and_pr_results(self): - logger = MagicMock() - results = [self._make_result("org/repo-a", PublishOutcome.UPDATED)] - pr_results = [self._make_pr_result("org/repo-a")] - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_summary(logger, results, pr_results, no_pr=False, dry_run=False) - mock_console.print.assert_called() - - def test_dry_run_suffix(self): - logger = MagicMock() - results = [self._make_result("org/repo-a", PublishOutcome.NO_CHANGE)] - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_summary(logger, results, [], no_pr=True, dry_run=True) - # Should surface dry-run in footer - footer_calls = str(logger.success.call_args_list) + str(logger.warning.call_args_list) - assert "dry-run" in footer_calls - - # --------------------------------------------------------------------------- -# marketplace list command -- lines 576-628 # --------------------------------------------------------------------------- diff --git a/tests/integration/test_marketplace_phase3w4.py b/tests/integration/test_marketplace_phase3w4.py index 4211f4d9b..6cdf9f0d3 100644 --- a/tests/integration/test_marketplace_phase3w4.py +++ b/tests/integration/test_marketplace_phase3w4.py @@ -21,7 +21,6 @@ _load_config_or_exit, _load_current_versions, _load_yml_or_exit, - _outcome_symbol, _OutdatedRow, _parse_marketplace_repo, _render_build_error, @@ -29,9 +28,6 @@ _render_check_table, _render_doctor_table, _render_outdated_table, - _render_publish_footer, - _render_publish_plan, - _render_publish_summary, _warn_duplicate_names, marketplace, search, @@ -45,7 +41,6 @@ OfflineMissError, RefNotFoundError, ) -from apm_cli.marketplace.publisher import ConsumerTarget, PublishOutcome, TargetResult # --------------------------------------------------------------------------- # MarketplaceGroup.format_commands -- lines 106-121 @@ -544,145 +539,10 @@ def test_with_console_rich_table(self): # --------------------------------------------------------------------------- -# _outcome_symbol -- lines 1243-1256 # --------------------------------------------------------------------------- -class TestOutcomeSymbol: - def test_updated_symbol(self): - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed_symbol(self): - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_downgrade_symbol(self): - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - def test_no_change_symbol(self): - assert _outcome_symbol(PublishOutcome.NO_CHANGE) == "[*]" - - -# --------------------------------------------------------------------------- -# _render_publish_footer -- lines 1259-1271 -# --------------------------------------------------------------------------- - - -class TestRenderPublishFooter: - def test_all_success_calls_logger_success(self): - logger = MagicMock() - _render_publish_footer(logger, updated=3, failed=0, total=3, dry_run=False) - logger.success.assert_called() - assert "3/3" in str(logger.success.call_args) - - def test_failures_calls_logger_warning(self): - logger = MagicMock() - _render_publish_footer(logger, updated=2, failed=1, total=3, dry_run=False) - logger.warning.assert_called() - assert "failed" in str(logger.warning.call_args) - - def test_dry_run_suffix_added(self): - logger = MagicMock() - _render_publish_footer(logger, updated=1, failed=0, total=1, dry_run=True) - call_str = str(logger.success.call_args) - assert "dry-run" in call_str - - -# --------------------------------------------------------------------------- -# _render_publish_plan -- lines 1120-1168 -# --------------------------------------------------------------------------- - - -class TestRenderPublishPlan: - def _make_plan(self, target_repos: list[str]): - plan = MagicMock() - plan.marketplace_name = "my-market" - plan.marketplace_version = "1.0.0" - plan.new_ref = "v1.0.0" - plan.branch_name = "release/v1.0.0" - targets = [] - for repo in target_repos: - t = MagicMock() - t.repo = repo - t.branch = "main" - t.path_in_repo = "apm.yml" - targets.append(t) - plan.targets = targets - return plan - - def test_no_console_colorama_fallback(self): - logger = MagicMock() - plan = self._make_plan(["org/repo-a", "org/repo-b"]) - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_plan(logger, plan) - logger.progress.assert_called() - assert logger.tree_item.call_count >= 2 - - def test_with_console_rich_panel(self): - logger = MagicMock() - plan = self._make_plan(["org/repo-a"]) - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_plan(logger, plan) - mock_console.print.assert_called() - - -# --------------------------------------------------------------------------- -# _render_publish_summary -- lines 1171-1240 -# --------------------------------------------------------------------------- - - -class TestRenderPublishSummary: - def _make_result(self, repo: str, outcome: PublishOutcome, message: str = "ok"): - r = MagicMock(spec=TargetResult) - t = MagicMock(spec=ConsumerTarget) - t.repo = repo - r.target = t - r.outcome = outcome - r.message = message - return r - - def _make_pr_result(self, repo: str, state_value: str = "open", pr_number: int = 42): - pr = MagicMock() - t = MagicMock() - t.repo = repo - pr.target = t - pr.state = MagicMock() - pr.state.value = state_value - pr.pr_number = pr_number - pr.pr_url = f"https://github.com/{repo}/pull/{pr_number}" - return pr - - def test_no_console_colorama_fallback_no_pr(self): - logger = MagicMock() - results = [ - self._make_result("org/repo-a", PublishOutcome.UPDATED), - self._make_result("org/repo-b", PublishOutcome.FAILED), - ] - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_summary(logger, results, [], no_pr=True, dry_run=False) - logger.tree_item.assert_called() - - def test_with_console_and_pr_results(self): - logger = MagicMock() - results = [self._make_result("org/repo-a", PublishOutcome.UPDATED)] - pr_results = [self._make_pr_result("org/repo-a")] - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_summary(logger, results, pr_results, no_pr=False, dry_run=False) - mock_console.print.assert_called() - - def test_dry_run_suffix(self): - logger = MagicMock() - results = [self._make_result("org/repo-a", PublishOutcome.NO_CHANGE)] - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_summary(logger, results, [], no_pr=True, dry_run=True) - # Should surface dry-run in footer - footer_calls = str(logger.success.call_args_list) + str(logger.warning.call_args_list) - assert "dry-run" in footer_calls - - # --------------------------------------------------------------------------- -# marketplace list command -- lines 576-628 # --------------------------------------------------------------------------- diff --git a/tests/integration/test_marketplace_publisher_flow.py b/tests/integration/test_marketplace_publisher_flow.py deleted file mode 100644 index 3630d13ab..000000000 --- a/tests/integration/test_marketplace_publisher_flow.py +++ /dev/null @@ -1,1486 +0,0 @@ -"""Integration tests for marketplace publisher and marketplace commands. - -Covers: -- ``src/apm_cli/commands/marketplace/__init__.py`` (gap ≈ 324 lines) -- ``src/apm_cli/marketplace/publisher.py`` (gap ≈ 296 lines) - -Strategy --------- -* CLI commands are exercised via Click's CliRunner. -* Registry / network calls are mocked at the boundary (never real HTTP). -* git operations in MarketplacePublisher are injected via the ``runner`` - constructor parameter (no real git subprocess). -* File I/O for PublishState is exercised against real tmp_path directories. -""" - -from __future__ import annotations - -import json -import re -import subprocess -from datetime import datetime, timezone -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -from click.testing import CliRunner - -from apm_cli.commands.marketplace import ( - _find_duplicate_names, - _is_valid_alias, - _load_targets_file, - _marketplace_add_unsupported_host_error, - _outcome_symbol, - _parse_marketplace_repo, - browse, - list_cmd, - marketplace, - remove, - search, - update, -) -from apm_cli.marketplace.errors import MarketplaceNotFoundError -from apm_cli.marketplace.models import ( - MarketplaceManifest, - MarketplacePlugin, - MarketplaceSource, -) -from apm_cli.marketplace.publisher import ( - ConsumerTarget, - MarketplacePublisher, - PublishOutcome, - PublishPlan, - PublishState, - TargetResult, - _sanitise_branch_segment, -) - -# --------------------------------------------------------------------------- -# Shared fixture data -# --------------------------------------------------------------------------- - -_MARKETPLACE_YML = """\ -name: acme-marketplace -description: Acme marketplace -version: 2.0.0 -owner: - name: Acme Corp -packages: - - name: tool-a - source: org/tool-a - version: "^1.0.0" - tags: - - test -""" - -_CONSUMER_APM_YML_WITH_REF = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v1.0.0 -""" - -_CONSUMER_APM_YML_NO_REF = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace -""" - -_CONSUMER_APM_YML_NO_APM = """\ -name: my-service -dependencies: - npm: - - lodash -""" - -_CONSUMER_APM_YML_NO_DEPS = """\ -name: my-service -""" - -_CONSUMER_APM_YML_WRONG_MARKETPLACE = """\ -name: my-service -dependencies: - apm: - - tool-a@other-marketplace#v1.0.0 -""" - - -def _make_mkt_root(tmp_path: Path) -> Path: - """Write a valid marketplace.yml and return the directory.""" - (tmp_path / "marketplace.yml").write_text(_MARKETPLACE_YML, encoding="utf-8") - return tmp_path - - -def _make_plan( - targets: list[ConsumerTarget] | None = None, - *, - allow_downgrade: bool = False, - allow_ref_change: bool = False, -) -> PublishPlan: - if targets is None: - targets = [ConsumerTarget(repo="consumer-org/svc-a", branch="main")] - return PublishPlan( - marketplace_name="acme-marketplace", - marketplace_version="2.0.0", - targets=tuple(targets), - commit_message="chore(apm): bump acme-marketplace to 2.0.0\n\nAPM-Publish-Id: deadbeef", - branch_name="apm/marketplace-update-acme-marketplace-2.0.0-deadbeef", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - short_hash="deadbeef", - allow_downgrade=allow_downgrade, - allow_ref_change=allow_ref_change, - ) - - -def _ok_process(cmd: list[str] | None = None) -> subprocess.CompletedProcess: - return subprocess.CompletedProcess(cmd or [], returncode=0, stdout="", stderr="") - - -def _fail_process(cmd: list[str] | None = None) -> subprocess.CompletedProcess: - proc = subprocess.CompletedProcess(cmd or [], returncode=1, stdout="", stderr="fatal: error") - return proc - - -def _make_source(name: str = "acme-tools") -> MarketplaceSource: - return MarketplaceSource( - name=name, - owner="acme", - repo="tools", - branch="main", - host="github.com", - path="marketplace.json", - ) - - -def _make_manifest( - plugins: list[MarketplacePlugin] | None = None, - *, - name: str = "acme-tools", -) -> MarketplaceManifest: - if plugins is None: - plugins = [ - MarketplacePlugin(name="plugin-a", description="A plugin"), - MarketplacePlugin(name="plugin-b", description="Security scanner", tags=("security",)), - ] - return MarketplaceManifest(name=name, plugins=tuple(plugins)) - - -# --------------------------------------------------------------------------- -# Section 1 - Pure helper functions (commands/__init__.py) -# --------------------------------------------------------------------------- - - -class TestIsValidAlias: - """Tests for _is_valid_alias.""" - - def test_simple_lowercase(self) -> None: - assert _is_valid_alias("acme-tools") is True - - def test_alphanumeric(self) -> None: - assert _is_valid_alias("tools123") is True - - def test_dots_allowed(self) -> None: - assert _is_valid_alias("my.tools") is True - - def test_underscores_allowed(self) -> None: - assert _is_valid_alias("my_tools") is True - - def test_empty_string_rejected(self) -> None: - assert _is_valid_alias("") is False - - def test_space_rejected(self) -> None: - assert _is_valid_alias("my tools") is False - - def test_at_sign_rejected(self) -> None: - assert _is_valid_alias("my@tools") is False - - def test_slash_rejected(self) -> None: - assert _is_valid_alias("my/tools") is False - - -class TestFindDuplicateNames: - """Tests for _find_duplicate_names.""" - - def _pkg(self, name: str) -> Any: - """Return a simple namespace with a .name attribute.""" - return SimpleNamespace(name=name) - - def test_no_duplicates_returns_empty(self) -> None: - yml = MagicMock() - yml.packages = [self._pkg("tool-a"), self._pkg("tool-b")] - result = _find_duplicate_names(yml) - assert result == "" - - def test_duplicate_returns_diagnostic(self) -> None: - yml = MagicMock() - yml.packages = [self._pkg("tool-a"), self._pkg("Tool-A")] # same when lowercased - result = _find_duplicate_names(yml) - assert "Duplicate names" in result - assert "tool-a" in result.lower() or "Tool-A" in result - - def test_case_insensitive_comparison(self) -> None: - yml = MagicMock() - yml.packages = [self._pkg("TOOL"), self._pkg("tool")] - result = _find_duplicate_names(yml) - assert result != "" - - def test_empty_packages_returns_empty(self) -> None: - yml = MagicMock() - yml.packages = [] - assert _find_duplicate_names(yml) == "" - - -class TestOutcomeSymbol: - """Tests for _outcome_symbol.""" - - def test_updated(self) -> None: - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed(self) -> None: - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_downgrade(self) -> None: - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - def test_skipped_ref_change(self) -> None: - assert _outcome_symbol(PublishOutcome.SKIPPED_REF_CHANGE) == "[!]" - - def test_no_change(self) -> None: - assert _outcome_symbol(PublishOutcome.NO_CHANGE) == "[*]" - - -class TestMarketplaceAddUnsupportedHostError: - """Tests for _marketplace_add_unsupported_host_error.""" - - def test_ado_host_specific_message(self) -> None: - msg = _marketplace_add_unsupported_host_error( - "dev.azure.com", "'dev.azure.com'", "'dev.azure.com'", "ado" - ) - assert "Azure DevOps" in msg or "not supported" in msg.lower() - assert "GitHub" in msg - - def test_generic_host_mentions_supported_hosts(self) -> None: - msg = _marketplace_add_unsupported_host_error( - "example.com", "'example.com'", "'example.com'", "generic" - ) - assert re.search(r"\bgithub\.com\b", msg) - assert "GITHUB_HOST" in msg or "GitLab" in msg - - -# --------------------------------------------------------------------------- -# Section 2 - _parse_marketplace_repo -# --------------------------------------------------------------------------- - - -class TestParseMarketplaceRepo: - """Tests for _parse_marketplace_repo.""" - - def test_simple_owner_repo(self) -> None: - url, kind, embedded = _parse_marketplace_repo("acme/tools", None) - assert url == "https://github.com/acme/tools" - assert kind == "github" - assert embedded == "github.com" - - def test_https_url(self) -> None: - url, kind, embedded = _parse_marketplace_repo("https://github.com/acme/tools", None) - assert url == "https://github.com/acme/tools" - assert kind == "github" - assert embedded == "github.com" - - def test_https_url_preserves_dot_git(self) -> None: - url, _kind, _embedded = _parse_marketplace_repo("https://github.com/acme/tools.git", None) - assert url == "https://github.com/acme/tools.git" - - def test_host_shorthand_three_segments(self) -> None: - url, kind, embedded = _parse_marketplace_repo("github.com/acme/tools", None) - assert url == "https://github.com/acme/tools" - assert kind == "github" - assert embedded == "github.com" - - def test_http_rejected(self) -> None: - with pytest.raises(ValueError, match="Insecure HTTP"): - _parse_marketplace_repo("http://github.com/acme/tools", None) - - def test_empty_string_raises(self) -> None: - with pytest.raises(ValueError, match="Empty"): - _parse_marketplace_repo("", None) - - def test_single_segment_raises(self) -> None: - with pytest.raises(ValueError): - _parse_marketplace_repo("acme", None) - - def test_control_character_raises(self) -> None: - with pytest.raises(ValueError, match="control"): - _parse_marketplace_repo("acme/\x00tools", None) - - def test_conflicting_host_raises(self) -> None: - with pytest.raises(ValueError, match="Conflicting host"): - _parse_marketplace_repo("https://github.com/acme/tools", "gitlab.com") - - def test_host_flag_normalised(self) -> None: - _url, _kind, embedded = _parse_marketplace_repo( - "https://github.com/acme/tools", "github.com" - ) - assert embedded == "github.com" - - def test_path_traversal_raises(self) -> None: - from apm_cli.utils.path_security import PathTraversalError - - with pytest.raises((ValueError, PathTraversalError)): - _parse_marketplace_repo("acme/../tools", None) - - def test_nested_path_owner(self) -> None: - """HOST/group/sub/repo -- multi-segment owner path.""" - url, _kind, embedded = _parse_marketplace_repo( - "https://gitlab.example.com/group/sub/repo", None - ) - assert url == "https://gitlab.example.com/group/sub/repo" - assert embedded == "gitlab.example.com" - - -# --------------------------------------------------------------------------- -# Section 3 - _load_targets_file -# --------------------------------------------------------------------------- - - -class TestLoadTargetsFile: - """Tests for _load_targets_file.""" - - def test_valid_single_target(self, tmp_path: Path) -> None: - content = "targets:\n - repo: org/svc\n branch: main\n" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - targets, err = _load_targets_file(f) - assert err is None - assert len(targets) == 1 - assert targets[0].repo == "org/svc" - assert targets[0].branch == "main" - - def test_valid_with_path_in_repo(self, tmp_path: Path) -> None: - content = ( - "targets:\n - repo: org/svc\n branch: main\n path_in_repo: config/apm.yml\n" - ) - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - targets, err = _load_targets_file(f) - assert err is None - assert targets[0].path_in_repo == "config/apm.yml" - - def test_missing_targets_key(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("foo: bar\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_empty_targets_list(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets: []\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_invalid_yaml(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets: [: bad yaml\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_missing_repo_field(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - branch: main\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert "repo" in err.lower() or err is not None - - def test_invalid_repo_format(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - repo: justname\n branch: main\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_missing_branch_field(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - repo: org/svc\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_path_traversal_in_path_in_repo(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text( - "targets:\n - repo: org/svc\n branch: main\n path_in_repo: ../etc/passwd\n", - encoding="utf-8", - ) - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_entry_not_a_mapping(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - just-a-string\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - -# --------------------------------------------------------------------------- -# Section 4 - ConsumerTarget validation (publisher.py) -# --------------------------------------------------------------------------- - - -class TestConsumerTarget: - """Tests for ConsumerTarget __post_init__ validation.""" - - def test_valid_target(self) -> None: - t = ConsumerTarget(repo="org/svc", branch="main") - assert t.repo == "org/svc" - - def test_invalid_repo_format_raises(self) -> None: - with pytest.raises(ValueError, match="owner/name"): - ConsumerTarget(repo="justname", branch="main") - - def test_repo_with_special_chars_raises(self) -> None: - with pytest.raises(ValueError): - ConsumerTarget(repo="org/svc!", branch="main") - - def test_branch_double_dot_raises(self) -> None: - with pytest.raises(ValueError): - ConsumerTarget(repo="org/svc", branch="feature/../../escape") - - def test_path_in_repo_traversal_raises(self) -> None: - from apm_cli.utils.path_security import PathTraversalError - - with pytest.raises((ValueError, PathTraversalError)): - ConsumerTarget(repo="org/svc", branch="main", path_in_repo="../etc/passwd") - - def test_custom_path_in_repo(self) -> None: - t = ConsumerTarget(repo="org/svc", branch="main", path_in_repo="config/apm.yml") - assert t.path_in_repo == "config/apm.yml" - - -# --------------------------------------------------------------------------- -# Section 5 - _sanitise_branch_segment (publisher.py) -# --------------------------------------------------------------------------- - - -class TestSanitiseBranchSegment: - """Tests for _sanitise_branch_segment.""" - - def test_safe_chars_unchanged(self) -> None: - assert _sanitise_branch_segment("acme-marketplace-2.0.0") == "acme-marketplace-2.0.0" - - def test_spaces_replaced_with_hyphens(self) -> None: - assert _sanitise_branch_segment("my marketplace") == "my-marketplace" - - def test_slash_replaced(self) -> None: - result = _sanitise_branch_segment("feature/branch") - assert "/" not in result - - def test_at_sign_replaced(self) -> None: - result = _sanitise_branch_segment("org@v2.0") - assert "@" not in result - - -# --------------------------------------------------------------------------- -# Section 6 - PublishState (publisher.py) -# --------------------------------------------------------------------------- - - -class TestPublishState: - """Tests for PublishState - state file write/read lifecycle.""" - - def test_load_from_missing_path_returns_fresh(self, tmp_path: Path) -> None: - state = PublishState.load(tmp_path / "nonexistent") - assert state.data["lastRun"] is None - assert state.data["history"] == [] - - def test_load_from_corrupt_json_returns_fresh(self, tmp_path: Path) -> None: - apm_dir = tmp_path / ".apm" - apm_dir.mkdir() - (apm_dir / "publish-state.json").write_text("{not json}", encoding="utf-8") - state = PublishState.load(tmp_path) - assert state.data["lastRun"] is None - - def test_begin_run_creates_last_run(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = _make_plan() - state.begin_run(plan) - assert state.data["lastRun"] is not None - assert state.data["lastRun"]["marketplaceName"] == "acme-marketplace" - - def test_begin_run_writes_state_file(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - state_path = tmp_path / ".apm" / "publish-state.json" - assert state_path.exists() - - def test_record_result_appended(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - target = ConsumerTarget(repo="org/svc", branch="main") - result = TargetResult( - target=target, outcome=PublishOutcome.UPDATED, message="ok", new_version="v2.0.0" - ) - state.record_result(result) - results = state.data["lastRun"]["results"] - assert len(results) == 1 - assert results[0]["outcome"] == "updated" - - def test_record_result_without_begin_run_is_noop(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - target = ConsumerTarget(repo="org/svc", branch="main") - result = TargetResult(target=target, outcome=PublishOutcome.FAILED, message="err") - state.record_result(result) # should not raise - assert state.data["lastRun"] is None - - def test_finalise_rotates_into_history(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - state.finalise(datetime.now(timezone.utc)) - assert len(state.data["history"]) == 1 - assert state.data["lastRun"]["finishedAt"] is not None - - def test_abort_marks_finished_at(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - state.abort("something went wrong") - assert "ABORTED" in state.data["lastRun"]["finishedAt"] - - def test_abort_without_begin_run_is_noop(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.abort("reason") # should not raise - - def test_data_property_returns_copy(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - d = state.data - d["injected"] = True - assert "injected" not in state.data - - def test_history_rotation_at_max(self, tmp_path: Path) -> None: - """After 11 runs, history should have at most 10 entries.""" - state = PublishState(tmp_path) - for _ in range(11): - state.begin_run(_make_plan()) - state.finalise(datetime.now(timezone.utc)) - assert len(state.data["history"]) <= 10 - - def test_load_valid_state_file(self, tmp_path: Path) -> None: - apm_dir = tmp_path / ".apm" - apm_dir.mkdir() - saved = { - "schemaVersion": 1, - "lastRun": { - "branchName": "apm/test", - "results": [], - "finishedAt": None, - "startedAt": "2024-01-01T00:00:00+00:00", - "marketplaceName": "test", - "marketplaceVersion": "1.0.0", - }, - "history": [], - } - (apm_dir / "publish-state.json").write_text(json.dumps(saved), encoding="utf-8") - state = PublishState.load(tmp_path) - assert state.data["lastRun"]["branchName"] == "apm/test" - - -# --------------------------------------------------------------------------- -# Section 7 - MarketplacePublisher.plan (publisher.py) -# --------------------------------------------------------------------------- - - -class TestMarketplacePublisherPlan: - """Tests for MarketplacePublisher.plan().""" - - def test_plan_returns_publish_plan(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path, runner=lambda *a, **kw: _ok_process()) - targets = [ConsumerTarget(repo="org/svc", branch="main")] - plan = pub.plan(targets) - assert isinstance(plan, PublishPlan) - - def test_plan_marketplace_name(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert plan.marketplace_name == "acme-marketplace" - - def test_plan_marketplace_version(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert plan.marketplace_version == "2.0.0" - - def test_plan_branch_name_deterministic(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - targets = [ConsumerTarget(repo="org/svc", branch="main")] - plan1 = pub.plan(targets) - plan2 = pub.plan(targets) - assert plan1.branch_name == plan2.branch_name - - def test_plan_branch_starts_with_apm_prefix(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert plan.branch_name.startswith("apm/marketplace-update-") - - def test_plan_commit_message_contains_marketplace_name(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert "acme-marketplace" in plan.commit_message - - def test_plan_commit_message_contains_apm_publish_id(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert "APM-Publish-Id:" in plan.commit_message - - def test_plan_new_ref_uses_tag_pattern(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - # default tag pattern is v{version} - assert plan.new_ref == "v2.0.0" - - def test_plan_with_allow_downgrade(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")], allow_downgrade=True) - assert plan.allow_downgrade is True - - def test_plan_with_allow_ref_change(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")], allow_ref_change=True) - assert plan.allow_ref_change is True - - def test_plan_targets_preserved(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - targets = [ - ConsumerTarget(repo="org/svc-a", branch="main"), - ConsumerTarget(repo="org/svc-b", branch="develop"), - ] - plan = pub.plan(targets) - assert len(plan.targets) == 2 - - def test_plan_short_hash_is_hex(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - int(plan.short_hash, 16) # should not raise - - def test_plan_no_marketplace_yml_raises(self, tmp_path: Path) -> None: - from apm_cli.marketplace.errors import MarketplaceYmlError - - pub = MarketplacePublisher(tmp_path) - with pytest.raises((MarketplaceYmlError, FileNotFoundError, Exception)): - pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - - -# --------------------------------------------------------------------------- -# Section 8 - MarketplacePublisher.execute + _process_single_target -# --------------------------------------------------------------------------- - - -def _build_runner( - *, - clone_apm_yml: str | None = None, - clone_dir_name: str = "repo", - clone_fail: bool = False, - checkout_fail: bool = False, - commit_fail: bool = False, - push_fail: bool = False, -) -> Any: - """Return a mock runner callable that simulates git operations. - - On clone success it creates the clone dir and optionally writes apm.yml. - """ - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - cwd = kwargs.get("cwd", "") - # Detect the operation by cmd content - if "clone" in cmd: - if clone_fail: - raise subprocess.CalledProcessError( - returncode=128, cmd=cmd, stderr="fatal: repository not found" - ) - clone_path = Path(cwd) / clone_dir_name - clone_path.mkdir(parents=True, exist_ok=True) - if clone_apm_yml is not None: - (clone_path / "apm.yml").write_text(clone_apm_yml, encoding="utf-8") - return _ok_process(cmd) - - if "checkout" in cmd: - if checkout_fail: - raise subprocess.CalledProcessError(returncode=1, cmd=cmd, stderr="error") - return _ok_process(cmd) - - if "commit" in cmd: - if commit_fail: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr="nothing to commit" - ) - return _ok_process(cmd) - - if "push" in cmd: - if push_fail: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr="fatal: push failed" - ) - return _ok_process(cmd) - - return _ok_process(cmd) - - return runner - - -class TestMarketplacePublisherExecute: - """Tests for MarketplacePublisher.execute() and _process_single_target.""" - - def test_execute_returns_list_of_results(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert isinstance(results, list) - assert len(results) == 1 - - def test_execute_happy_path_updated(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_no_change_when_already_at_new_ref(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - already_updated = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v2.0.0 -""" - runner = _build_runner(clone_apm_yml=already_updated) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.NO_CHANGE - - def test_execute_clone_failure_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_fail=True) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_creates_state_file(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - pub.execute(plan, dry_run=True) - state_path = tmp_path / ".apm" / "publish-state.json" - assert state_path.exists() - - def test_execute_dry_run_no_push_called(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - push_called = [] - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - if "push" in cmd: - push_called.append(True) - if "clone" in cmd: - clone_path = Path(kwargs["cwd"]) / "repo" - clone_path.mkdir(parents=True, exist_ok=True) - (clone_path / "apm.yml").write_text(_CONSUMER_APM_YML_WITH_REF, encoding="utf-8") - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - pub.execute(plan, dry_run=True) - assert not push_called, "push should not be called in dry_run mode" - - def test_execute_push_called_when_not_dry_run(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - push_called = [] - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - if "push" in cmd: - push_called.append(True) - if "clone" in cmd: - clone_path = Path(kwargs["cwd"]) / "repo" - clone_path.mkdir(parents=True, exist_ok=True) - (clone_path / "apm.yml").write_text(_CONSUMER_APM_YML_WITH_REF, encoding="utf-8") - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - pub.execute(plan, dry_run=False) - assert push_called, "push should be called when not dry_run" - - def test_execute_missing_apm_yml_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - # Don't create apm.yml in clone dir - runner = _build_runner(clone_apm_yml=None) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - assert "not found" in results[0].message.lower() - - def test_execute_no_dependencies_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_DEPS) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_no_apm_deps_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_APM) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_wrong_marketplace_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WRONG_MARKETPLACE) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_downgrade_guard(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - # Current ref is v3.0.0, new ref is v2.0.0 -> downgrade - newer_yml = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v3.0.0 -""" - runner = _build_runner(clone_apm_yml=newer_yml) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_downgrade=False) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.SKIPPED_DOWNGRADE - - def test_execute_downgrade_allowed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - newer_yml = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v3.0.0 -""" - runner = _build_runner(clone_apm_yml=newer_yml) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_downgrade=True) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_ref_change_implicit_to_explicit(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_ref_change=False) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.SKIPPED_REF_CHANGE - - def test_execute_ref_change_allowed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_ref_change=True) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_invalid_yaml_in_apm_yml_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml="{not: valid: yaml: [\n") - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_apm_yml_not_a_mapping_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml="- just\n- a\n- list\n") - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_multiple_targets_ordered(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - targets = [ - ConsumerTarget(repo="org/svc-a", branch="main"), - ConsumerTarget(repo="org/svc-b", branch="main"), - ] - plan = _make_plan(targets) - results = pub.execute(plan, dry_run=True) - assert len(results) == 2 - - def test_execute_push_failure_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF, push_fail=True) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=False) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_updated_message_contains_old_and_new(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - assert "v1.0.0" in results[0].message or "acme-marketplace" in results[0].message - - -# --------------------------------------------------------------------------- -# Section 9 - MarketplacePublisher.safe_force_push (publisher.py) -# --------------------------------------------------------------------------- - - -class TestSafeForPush: - """Tests for MarketplacePublisher.safe_force_push.""" - - def test_trailer_match_returns_true(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - expected_trailer = "abc12345" - commit_msg = f"chore: update\n\nAPM-Publish-Id: {expected_trailer}" - call_count = [] - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - call_count.append(cmd) - if "log" in cmd: - return subprocess.CompletedProcess(cmd, returncode=0, stdout=commit_msg, stderr="") - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - result = pub.safe_force_push("origin", "apm/test", expected_trailer) - assert result is True - - def test_trailer_mismatch_returns_false(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - if "log" in cmd: - return subprocess.CompletedProcess( - cmd, returncode=0, stdout="chore: no trailer", stderr="" - ) - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - result = pub.safe_force_push("origin", "apm/test", "deadbeef") - assert result is False - - def test_exception_returns_false(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - raise subprocess.CalledProcessError(returncode=128, cmd=cmd, stderr="error") - - pub = MarketplacePublisher(tmp_path, runner=runner) - result = pub.safe_force_push("origin", "apm/test", "deadbeef") - assert result is False - - -# --------------------------------------------------------------------------- -# Section 10 - CLI commands via CliRunner -# --------------------------------------------------------------------------- - - -def _mock_source(name: str = "acme-tools") -> MarketplaceSource: - return MarketplaceSource( - name=name, owner="acme", repo="tools", branch="main", host="github.com" - ) - - -class TestListCommand: - """Tests for `apm marketplace list`.""" - - def test_empty_registry_shows_info(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[], - ): - result = runner.invoke(list_cmd, [], catch_exceptions=False) - assert result.exit_code == 0 - assert "No marketplaces" in result.output or "registered" in result.output.lower() - - def test_with_sources_shows_them(self) -> None: - runner = CliRunner() - source = _mock_source() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[source], - ): - result = runner.invoke(list_cmd, [], catch_exceptions=False) - assert result.exit_code == 0 - assert "acme-tools" in result.output or "acme" in result.output - - def test_no_traceback(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[], - ): - result = runner.invoke(list_cmd, [], catch_exceptions=False) - assert "Traceback" not in result.output - - -class TestRemoveCommand: - """Tests for `apm marketplace remove`.""" - - def test_success_with_yes_flag(self) -> None: - runner = CliRunner() - source = _mock_source() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch("apm_cli.marketplace.registry.remove_marketplace"), - patch("apm_cli.marketplace.client.clear_marketplace_cache"), - ): - result = runner.invoke(remove, ["acme-tools", "--yes"], catch_exceptions=False) - assert result.exit_code == 0 - assert "acme-tools" in result.output or "removed" in result.output.lower() - - def test_non_interactive_without_yes_exits_1(self) -> None: - runner = CliRunner() - source = _mock_source() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.commands._helpers._is_interactive", - return_value=False, - ), - ): - result = runner.invoke(remove, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_not_found_exits_1(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - side_effect=MarketplaceNotFoundError("acme-tools"), - ): - result = runner.invoke(remove, ["acme-tools", "--yes"], catch_exceptions=False) - assert result.exit_code == 1 - - -class TestUpdateCommand: - """Tests for `apm marketplace update`.""" - - def test_update_specific_name(self) -> None: - runner = CliRunner() - source = _mock_source() - manifest = _make_manifest() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch("apm_cli.marketplace.client.clear_marketplace_cache"), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(update, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "acme-tools" in result.output or "updated" in result.output.lower() - - def test_update_all_empty_registry(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[], - ): - result = runner.invoke(update, [], catch_exceptions=False) - assert result.exit_code == 0 - assert "No marketplaces" in result.output or "registered" in result.output.lower() - - def test_update_all_multiple_sources(self) -> None: - runner = CliRunner() - sources = [_mock_source("mkt-a"), _mock_source("mkt-b")] - manifest = _make_manifest() - with ( - patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=sources, - ), - patch("apm_cli.marketplace.client.clear_marketplace_cache"), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(update, [], catch_exceptions=False) - assert result.exit_code == 0 - - -class TestBrowseCommand: - """Tests for `apm marketplace browse`.""" - - def test_browse_with_plugins(self) -> None: - runner = CliRunner() - source = _mock_source() - manifest = _make_manifest() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(browse, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "plugin-a" in result.output or "plugin" in result.output.lower() - - def test_browse_empty_plugins(self) -> None: - runner = CliRunner() - source = _mock_source() - manifest = _make_manifest(plugins=[]) - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(browse, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "no plugins" in result.output.lower() or "0" in result.output - - def test_browse_not_found_exits_1(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - side_effect=Exception("not found"), - ): - result = runner.invoke(browse, ["nonexistent"], catch_exceptions=False) - assert result.exit_code == 1 - - -class TestSearchCommand: - """Tests for `apm marketplace search`.""" - - def test_search_missing_at_sign_exits_1(self) -> None: - runner = CliRunner() - result = runner.invoke(search, ["noseparator"], catch_exceptions=False) - assert result.exit_code == 1 - assert "@" in result.output - - def test_search_empty_query_exits_1(self) -> None: - runner = CliRunner() - result = runner.invoke(search, ["@acme-tools"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_search_empty_marketplace_exits_1(self) -> None: - runner = CliRunner() - result = runner.invoke(search, ["query@"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_search_unregistered_marketplace_exits_1(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - side_effect=MarketplaceNotFoundError("unknown"), - ): - result = runner.invoke(search, ["security@unknown"], catch_exceptions=False) - assert result.exit_code == 1 - assert "not registered" in result.output.lower() or "unknown" in result.output - - def test_search_no_results(self) -> None: - runner = CliRunner() - source = _mock_source() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.search_marketplace", - return_value=[], - ), - ): - result = runner.invoke(search, ["xyzzy@acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "no plugins" in result.output.lower() or "not found" in result.output.lower() - - def test_search_with_results(self) -> None: - runner = CliRunner() - source = _mock_source() - plugins = [MarketplacePlugin(name="security-scanner", description="Security tool")] - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.search_marketplace", - return_value=plugins, - ), - ): - result = runner.invoke(search, ["security@acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "security-scanner" in result.output or "security" in result.output.lower() - - def test_search_respects_limit(self) -> None: - runner = CliRunner() - source = _mock_source() - plugins = [MarketplacePlugin(name=f"p{i}") for i in range(25)] - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.search_marketplace", - return_value=plugins, - ), - ): - result = runner.invoke( - search, ["query@acme-tools", "--limit", "5"], catch_exceptions=False - ) - assert result.exit_code == 0 - - -class TestMarketplaceGroupBuildDeprecated: - """Tests that 'apm marketplace build' is rejected with a clear error.""" - - def test_build_raises_usage_error(self) -> None: - runner = CliRunner() - result = runner.invoke(marketplace, ["build"], catch_exceptions=False) - # Should exit with non-zero or raise a UsageError message - assert result.exit_code != 0 or "build" in result.output.lower() - # The error message should mention 'apm pack' - assert "pack" in result.output or "removed" in result.output - - -# --------------------------------------------------------------------------- -# Section 11 - PublishPlan field assertions -# --------------------------------------------------------------------------- - - -class TestPublishPlan: - """Tests for PublishPlan dataclass fields.""" - - def test_publish_plan_fields(self) -> None: - targets = (ConsumerTarget(repo="org/svc", branch="main"),) - plan = PublishPlan( - marketplace_name="test-mkt", - marketplace_version="1.0.0", - targets=targets, - commit_message="chore: bump", - branch_name="apm/test", - new_ref="v1.0.0", - tag_pattern_used="v{version}", - short_hash="abcd1234", - ) - assert plan.marketplace_name == "test-mkt" - assert plan.marketplace_version == "1.0.0" - assert len(plan.targets) == 1 - assert plan.allow_downgrade is False - assert plan.allow_ref_change is False - assert plan.target_package is None - - def test_publish_plan_with_target_package(self) -> None: - plan = PublishPlan( - marketplace_name="test", - marketplace_version="1.0.0", - targets=(ConsumerTarget(repo="org/svc", branch="main"),), - commit_message="msg", - branch_name="apm/test", - new_ref="v1.0.0", - tag_pattern_used="v{version}", - target_package="tool-a", - ) - assert plan.target_package == "tool-a" - - -# --------------------------------------------------------------------------- -# Section 12 - PublishOutcome enum -# --------------------------------------------------------------------------- - - -class TestPublishOutcomeEnum: - """Tests for PublishOutcome enum values.""" - - def test_all_values_unique(self) -> None: - values = [o.value for o in PublishOutcome] - assert len(values) == len(set(values)) - - def test_string_subclass(self) -> None: - assert isinstance(PublishOutcome.UPDATED.value, str) - - def test_updated_value(self) -> None: - assert PublishOutcome.UPDATED.value == "updated" - - def test_failed_value(self) -> None: - assert PublishOutcome.FAILED.value == "failed" - - def test_no_change_value(self) -> None: - assert PublishOutcome.NO_CHANGE.value == "no-change" - - -# --------------------------------------------------------------------------- -# Section 13 - _check_gitignore_for_marketplace_json -# --------------------------------------------------------------------------- - - -class TestCheckGitignoreForMarketplaceJson: - """Tests for _check_gitignore_for_marketplace_json (via CliRunner CWD).""" - - def test_no_gitignore_no_warning(self, tmp_path: Path) -> None: - """No .gitignore file should not produce a warning.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_not_called() - - def test_gitignore_with_matching_pattern_warns(self, tmp_path: Path) -> None: - """A .gitignore line matching 'marketplace.json' should warn.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - (tmp_path / ".gitignore").write_text("marketplace.json\n", encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_called_once() - - def test_gitignore_comment_line_ignored(self, tmp_path: Path) -> None: - """Comment lines in .gitignore should not trigger a warning.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - (tmp_path / ".gitignore").write_text("# marketplace.json\n", encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_not_called() - - def test_gitignore_json_wildcard_warns(self, tmp_path: Path) -> None: - """A *.json rule in .gitignore should warn.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - (tmp_path / ".gitignore").write_text("*.json\n", encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_called_once() - - -# --------------------------------------------------------------------------- -# Section 14 - _load_yml_or_exit (indirectly via CWD + sys.exit mocking) -# --------------------------------------------------------------------------- - - -class TestLoadYmlOrExit: - """Tests for _load_yml_or_exit helper.""" - - def test_missing_yml_exits_1(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_yml_or_exit - - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - with pytest.raises(SystemExit) as exc_info: - _load_yml_or_exit(logger_mock) - finally: - os.chdir(old_cwd) - assert exc_info.value.code == 1 - - def test_valid_yml_returns_config(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_yml_or_exit - - (tmp_path / "marketplace.yml").write_text(_MARKETPLACE_YML, encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - yml = _load_yml_or_exit(logger_mock) - finally: - os.chdir(old_cwd) - assert yml.name == "acme-marketplace" - - def test_invalid_yml_exits_2(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_yml_or_exit - - (tmp_path / "marketplace.yml").write_text( - "name: ~\nversion: not-semver\n", encoding="utf-8" - ) - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - with pytest.raises(SystemExit) as exc_info: - _load_yml_or_exit(logger_mock) - finally: - os.chdir(old_cwd) - # Schema errors may produce exit code 1 or 2 - assert exc_info.value.code in (1, 2) diff --git a/tests/integration/test_marketplace_publisher_phase3.py b/tests/integration/test_marketplace_publisher_phase3.py deleted file mode 100644 index 92e18db51..000000000 --- a/tests/integration/test_marketplace_publisher_phase3.py +++ /dev/null @@ -1,1486 +0,0 @@ -"""Phase-3 integration tests for marketplace publisher and marketplace commands. - -Covers: -- ``src/apm_cli/commands/marketplace/__init__.py`` (gap ≈ 324 lines) -- ``src/apm_cli/marketplace/publisher.py`` (gap ≈ 296 lines) - -Strategy --------- -* CLI commands are exercised via Click's CliRunner. -* Registry / network calls are mocked at the boundary (never real HTTP). -* git operations in MarketplacePublisher are injected via the ``runner`` - constructor parameter (no real git subprocess). -* File I/O for PublishState is exercised against real tmp_path directories. -""" - -from __future__ import annotations - -import json -import re -import subprocess -from datetime import datetime, timezone -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -from click.testing import CliRunner - -from apm_cli.commands.marketplace import ( - _find_duplicate_names, - _is_valid_alias, - _load_targets_file, - _marketplace_add_unsupported_host_error, - _outcome_symbol, - _parse_marketplace_repo, - browse, - list_cmd, - marketplace, - remove, - search, - update, -) -from apm_cli.marketplace.errors import MarketplaceNotFoundError -from apm_cli.marketplace.models import ( - MarketplaceManifest, - MarketplacePlugin, - MarketplaceSource, -) -from apm_cli.marketplace.publisher import ( - ConsumerTarget, - MarketplacePublisher, - PublishOutcome, - PublishPlan, - PublishState, - TargetResult, - _sanitise_branch_segment, -) - -# --------------------------------------------------------------------------- -# Shared fixture data -# --------------------------------------------------------------------------- - -_MARKETPLACE_YML = """\ -name: acme-marketplace -description: Acme marketplace -version: 2.0.0 -owner: - name: Acme Corp -packages: - - name: tool-a - source: org/tool-a - version: "^1.0.0" - tags: - - test -""" - -_CONSUMER_APM_YML_WITH_REF = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v1.0.0 -""" - -_CONSUMER_APM_YML_NO_REF = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace -""" - -_CONSUMER_APM_YML_NO_APM = """\ -name: my-service -dependencies: - npm: - - lodash -""" - -_CONSUMER_APM_YML_NO_DEPS = """\ -name: my-service -""" - -_CONSUMER_APM_YML_WRONG_MARKETPLACE = """\ -name: my-service -dependencies: - apm: - - tool-a@other-marketplace#v1.0.0 -""" - - -def _make_mkt_root(tmp_path: Path) -> Path: - """Write a valid marketplace.yml and return the directory.""" - (tmp_path / "marketplace.yml").write_text(_MARKETPLACE_YML, encoding="utf-8") - return tmp_path - - -def _make_plan( - targets: list[ConsumerTarget] | None = None, - *, - allow_downgrade: bool = False, - allow_ref_change: bool = False, -) -> PublishPlan: - if targets is None: - targets = [ConsumerTarget(repo="consumer-org/svc-a", branch="main")] - return PublishPlan( - marketplace_name="acme-marketplace", - marketplace_version="2.0.0", - targets=tuple(targets), - commit_message="chore(apm): bump acme-marketplace to 2.0.0\n\nAPM-Publish-Id: deadbeef", - branch_name="apm/marketplace-update-acme-marketplace-2.0.0-deadbeef", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - short_hash="deadbeef", - allow_downgrade=allow_downgrade, - allow_ref_change=allow_ref_change, - ) - - -def _ok_process(cmd: list[str] | None = None) -> subprocess.CompletedProcess: - return subprocess.CompletedProcess(cmd or [], returncode=0, stdout="", stderr="") - - -def _fail_process(cmd: list[str] | None = None) -> subprocess.CompletedProcess: - proc = subprocess.CompletedProcess(cmd or [], returncode=1, stdout="", stderr="fatal: error") - return proc - - -def _make_source(name: str = "acme-tools") -> MarketplaceSource: - return MarketplaceSource( - name=name, - owner="acme", - repo="tools", - branch="main", - host="github.com", - path="marketplace.json", - ) - - -def _make_manifest( - plugins: list[MarketplacePlugin] | None = None, - *, - name: str = "acme-tools", -) -> MarketplaceManifest: - if plugins is None: - plugins = [ - MarketplacePlugin(name="plugin-a", description="A plugin"), - MarketplacePlugin(name="plugin-b", description="Security scanner", tags=("security",)), - ] - return MarketplaceManifest(name=name, plugins=tuple(plugins)) - - -# --------------------------------------------------------------------------- -# Section 1 - Pure helper functions (commands/__init__.py) -# --------------------------------------------------------------------------- - - -class TestIsValidAlias: - """Tests for _is_valid_alias.""" - - def test_simple_lowercase(self) -> None: - assert _is_valid_alias("acme-tools") is True - - def test_alphanumeric(self) -> None: - assert _is_valid_alias("tools123") is True - - def test_dots_allowed(self) -> None: - assert _is_valid_alias("my.tools") is True - - def test_underscores_allowed(self) -> None: - assert _is_valid_alias("my_tools") is True - - def test_empty_string_rejected(self) -> None: - assert _is_valid_alias("") is False - - def test_space_rejected(self) -> None: - assert _is_valid_alias("my tools") is False - - def test_at_sign_rejected(self) -> None: - assert _is_valid_alias("my@tools") is False - - def test_slash_rejected(self) -> None: - assert _is_valid_alias("my/tools") is False - - -class TestFindDuplicateNames: - """Tests for _find_duplicate_names.""" - - def _pkg(self, name: str) -> Any: - """Return a simple namespace with a .name attribute.""" - return SimpleNamespace(name=name) - - def test_no_duplicates_returns_empty(self) -> None: - yml = MagicMock() - yml.packages = [self._pkg("tool-a"), self._pkg("tool-b")] - result = _find_duplicate_names(yml) - assert result == "" - - def test_duplicate_returns_diagnostic(self) -> None: - yml = MagicMock() - yml.packages = [self._pkg("tool-a"), self._pkg("Tool-A")] # same when lowercased - result = _find_duplicate_names(yml) - assert "Duplicate names" in result - assert "tool-a" in result.lower() or "Tool-A" in result - - def test_case_insensitive_comparison(self) -> None: - yml = MagicMock() - yml.packages = [self._pkg("TOOL"), self._pkg("tool")] - result = _find_duplicate_names(yml) - assert result != "" - - def test_empty_packages_returns_empty(self) -> None: - yml = MagicMock() - yml.packages = [] - assert _find_duplicate_names(yml) == "" - - -class TestOutcomeSymbol: - """Tests for _outcome_symbol.""" - - def test_updated(self) -> None: - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed(self) -> None: - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_downgrade(self) -> None: - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - def test_skipped_ref_change(self) -> None: - assert _outcome_symbol(PublishOutcome.SKIPPED_REF_CHANGE) == "[!]" - - def test_no_change(self) -> None: - assert _outcome_symbol(PublishOutcome.NO_CHANGE) == "[*]" - - -class TestMarketplaceAddUnsupportedHostError: - """Tests for _marketplace_add_unsupported_host_error.""" - - def test_ado_host_specific_message(self) -> None: - msg = _marketplace_add_unsupported_host_error( - "dev.azure.com", "'dev.azure.com'", "'dev.azure.com'", "ado" - ) - assert "Azure DevOps" in msg or "not supported" in msg.lower() - assert "GitHub" in msg - - def test_generic_host_mentions_supported_hosts(self) -> None: - msg = _marketplace_add_unsupported_host_error( - "example.com", "'example.com'", "'example.com'", "generic" - ) - assert re.search(r"\bgithub\.com\b", msg) - assert "GITHUB_HOST" in msg or "GitLab" in msg - - -# --------------------------------------------------------------------------- -# Section 2 - _parse_marketplace_repo -# --------------------------------------------------------------------------- - - -class TestParseMarketplaceRepo: - """Tests for _parse_marketplace_repo.""" - - def test_simple_owner_repo(self) -> None: - url, kind, embedded = _parse_marketplace_repo("acme/tools", None) - assert url == "https://github.com/acme/tools" - assert kind == "github" - assert embedded == "github.com" - - def test_https_url(self) -> None: - url, kind, embedded = _parse_marketplace_repo("https://github.com/acme/tools", None) - assert url == "https://github.com/acme/tools" - assert kind == "github" - assert embedded == "github.com" - - def test_https_url_preserves_dot_git(self) -> None: - url, _kind, _embedded = _parse_marketplace_repo("https://github.com/acme/tools.git", None) - assert url == "https://github.com/acme/tools.git" - - def test_host_shorthand_three_segments(self) -> None: - url, kind, embedded = _parse_marketplace_repo("github.com/acme/tools", None) - assert url == "https://github.com/acme/tools" - assert kind == "github" - assert embedded == "github.com" - - def test_http_rejected(self) -> None: - with pytest.raises(ValueError, match="Insecure HTTP"): - _parse_marketplace_repo("http://github.com/acme/tools", None) - - def test_empty_string_raises(self) -> None: - with pytest.raises(ValueError, match="Empty"): - _parse_marketplace_repo("", None) - - def test_single_segment_raises(self) -> None: - with pytest.raises(ValueError): - _parse_marketplace_repo("acme", None) - - def test_control_character_raises(self) -> None: - with pytest.raises(ValueError, match="control"): - _parse_marketplace_repo("acme/\x00tools", None) - - def test_conflicting_host_raises(self) -> None: - with pytest.raises(ValueError, match="Conflicting host"): - _parse_marketplace_repo("https://github.com/acme/tools", "gitlab.com") - - def test_host_flag_normalised(self) -> None: - _url, _kind, embedded = _parse_marketplace_repo( - "https://github.com/acme/tools", "github.com" - ) - assert embedded == "github.com" - - def test_path_traversal_raises(self) -> None: - from apm_cli.utils.path_security import PathTraversalError - - with pytest.raises((ValueError, PathTraversalError)): - _parse_marketplace_repo("acme/../tools", None) - - def test_nested_path_owner(self) -> None: - """HOST/group/sub/repo -- multi-segment owner path.""" - url, _kind, embedded = _parse_marketplace_repo( - "https://gitlab.example.com/group/sub/repo", None - ) - assert url == "https://gitlab.example.com/group/sub/repo" - assert embedded == "gitlab.example.com" - - -# --------------------------------------------------------------------------- -# Section 3 - _load_targets_file -# --------------------------------------------------------------------------- - - -class TestLoadTargetsFile: - """Tests for _load_targets_file.""" - - def test_valid_single_target(self, tmp_path: Path) -> None: - content = "targets:\n - repo: org/svc\n branch: main\n" - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - targets, err = _load_targets_file(f) - assert err is None - assert len(targets) == 1 - assert targets[0].repo == "org/svc" - assert targets[0].branch == "main" - - def test_valid_with_path_in_repo(self, tmp_path: Path) -> None: - content = ( - "targets:\n - repo: org/svc\n branch: main\n path_in_repo: config/apm.yml\n" - ) - f = tmp_path / "targets.yml" - f.write_text(content, encoding="utf-8") - targets, err = _load_targets_file(f) - assert err is None - assert targets[0].path_in_repo == "config/apm.yml" - - def test_missing_targets_key(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("foo: bar\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_empty_targets_list(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets: []\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_invalid_yaml(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets: [: bad yaml\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_missing_repo_field(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - branch: main\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert "repo" in err.lower() or err is not None - - def test_invalid_repo_format(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - repo: justname\n branch: main\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_missing_branch_field(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - repo: org/svc\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_path_traversal_in_path_in_repo(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text( - "targets:\n - repo: org/svc\n branch: main\n path_in_repo: ../etc/passwd\n", - encoding="utf-8", - ) - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - def test_entry_not_a_mapping(self, tmp_path: Path) -> None: - f = tmp_path / "targets.yml" - f.write_text("targets:\n - just-a-string\n", encoding="utf-8") - targets, err = _load_targets_file(f) - assert targets is None - assert err is not None - - -# --------------------------------------------------------------------------- -# Section 4 - ConsumerTarget validation (publisher.py) -# --------------------------------------------------------------------------- - - -class TestConsumerTarget: - """Tests for ConsumerTarget __post_init__ validation.""" - - def test_valid_target(self) -> None: - t = ConsumerTarget(repo="org/svc", branch="main") - assert t.repo == "org/svc" - - def test_invalid_repo_format_raises(self) -> None: - with pytest.raises(ValueError, match="owner/name"): - ConsumerTarget(repo="justname", branch="main") - - def test_repo_with_special_chars_raises(self) -> None: - with pytest.raises(ValueError): - ConsumerTarget(repo="org/svc!", branch="main") - - def test_branch_double_dot_raises(self) -> None: - with pytest.raises(ValueError): - ConsumerTarget(repo="org/svc", branch="feature/../../escape") - - def test_path_in_repo_traversal_raises(self) -> None: - from apm_cli.utils.path_security import PathTraversalError - - with pytest.raises((ValueError, PathTraversalError)): - ConsumerTarget(repo="org/svc", branch="main", path_in_repo="../etc/passwd") - - def test_custom_path_in_repo(self) -> None: - t = ConsumerTarget(repo="org/svc", branch="main", path_in_repo="config/apm.yml") - assert t.path_in_repo == "config/apm.yml" - - -# --------------------------------------------------------------------------- -# Section 5 - _sanitise_branch_segment (publisher.py) -# --------------------------------------------------------------------------- - - -class TestSanitiseBranchSegment: - """Tests for _sanitise_branch_segment.""" - - def test_safe_chars_unchanged(self) -> None: - assert _sanitise_branch_segment("acme-marketplace-2.0.0") == "acme-marketplace-2.0.0" - - def test_spaces_replaced_with_hyphens(self) -> None: - assert _sanitise_branch_segment("my marketplace") == "my-marketplace" - - def test_slash_replaced(self) -> None: - result = _sanitise_branch_segment("feature/branch") - assert "/" not in result - - def test_at_sign_replaced(self) -> None: - result = _sanitise_branch_segment("org@v2.0") - assert "@" not in result - - -# --------------------------------------------------------------------------- -# Section 6 - PublishState (publisher.py) -# --------------------------------------------------------------------------- - - -class TestPublishState: - """Tests for PublishState - state file write/read lifecycle.""" - - def test_load_from_missing_path_returns_fresh(self, tmp_path: Path) -> None: - state = PublishState.load(tmp_path / "nonexistent") - assert state.data["lastRun"] is None - assert state.data["history"] == [] - - def test_load_from_corrupt_json_returns_fresh(self, tmp_path: Path) -> None: - apm_dir = tmp_path / ".apm" - apm_dir.mkdir() - (apm_dir / "publish-state.json").write_text("{not json}", encoding="utf-8") - state = PublishState.load(tmp_path) - assert state.data["lastRun"] is None - - def test_begin_run_creates_last_run(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = _make_plan() - state.begin_run(plan) - assert state.data["lastRun"] is not None - assert state.data["lastRun"]["marketplaceName"] == "acme-marketplace" - - def test_begin_run_writes_state_file(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - state_path = tmp_path / ".apm" / "publish-state.json" - assert state_path.exists() - - def test_record_result_appended(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - target = ConsumerTarget(repo="org/svc", branch="main") - result = TargetResult( - target=target, outcome=PublishOutcome.UPDATED, message="ok", new_version="v2.0.0" - ) - state.record_result(result) - results = state.data["lastRun"]["results"] - assert len(results) == 1 - assert results[0]["outcome"] == "updated" - - def test_record_result_without_begin_run_is_noop(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - target = ConsumerTarget(repo="org/svc", branch="main") - result = TargetResult(target=target, outcome=PublishOutcome.FAILED, message="err") - state.record_result(result) # should not raise - assert state.data["lastRun"] is None - - def test_finalise_rotates_into_history(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - state.finalise(datetime.now(timezone.utc)) - assert len(state.data["history"]) == 1 - assert state.data["lastRun"]["finishedAt"] is not None - - def test_abort_marks_finished_at(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.begin_run(_make_plan()) - state.abort("something went wrong") - assert "ABORTED" in state.data["lastRun"]["finishedAt"] - - def test_abort_without_begin_run_is_noop(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - state.abort("reason") # should not raise - - def test_data_property_returns_copy(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - d = state.data - d["injected"] = True - assert "injected" not in state.data - - def test_history_rotation_at_max(self, tmp_path: Path) -> None: - """After 11 runs, history should have at most 10 entries.""" - state = PublishState(tmp_path) - for _ in range(11): - state.begin_run(_make_plan()) - state.finalise(datetime.now(timezone.utc)) - assert len(state.data["history"]) <= 10 - - def test_load_valid_state_file(self, tmp_path: Path) -> None: - apm_dir = tmp_path / ".apm" - apm_dir.mkdir() - saved = { - "schemaVersion": 1, - "lastRun": { - "branchName": "apm/test", - "results": [], - "finishedAt": None, - "startedAt": "2024-01-01T00:00:00+00:00", - "marketplaceName": "test", - "marketplaceVersion": "1.0.0", - }, - "history": [], - } - (apm_dir / "publish-state.json").write_text(json.dumps(saved), encoding="utf-8") - state = PublishState.load(tmp_path) - assert state.data["lastRun"]["branchName"] == "apm/test" - - -# --------------------------------------------------------------------------- -# Section 7 - MarketplacePublisher.plan (publisher.py) -# --------------------------------------------------------------------------- - - -class TestMarketplacePublisherPlan: - """Tests for MarketplacePublisher.plan().""" - - def test_plan_returns_publish_plan(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path, runner=lambda *a, **kw: _ok_process()) - targets = [ConsumerTarget(repo="org/svc", branch="main")] - plan = pub.plan(targets) - assert isinstance(plan, PublishPlan) - - def test_plan_marketplace_name(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert plan.marketplace_name == "acme-marketplace" - - def test_plan_marketplace_version(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert plan.marketplace_version == "2.0.0" - - def test_plan_branch_name_deterministic(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - targets = [ConsumerTarget(repo="org/svc", branch="main")] - plan1 = pub.plan(targets) - plan2 = pub.plan(targets) - assert plan1.branch_name == plan2.branch_name - - def test_plan_branch_starts_with_apm_prefix(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert plan.branch_name.startswith("apm/marketplace-update-") - - def test_plan_commit_message_contains_marketplace_name(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert "acme-marketplace" in plan.commit_message - - def test_plan_commit_message_contains_apm_publish_id(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - assert "APM-Publish-Id:" in plan.commit_message - - def test_plan_new_ref_uses_tag_pattern(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - # default tag pattern is v{version} - assert plan.new_ref == "v2.0.0" - - def test_plan_with_allow_downgrade(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")], allow_downgrade=True) - assert plan.allow_downgrade is True - - def test_plan_with_allow_ref_change(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")], allow_ref_change=True) - assert plan.allow_ref_change is True - - def test_plan_targets_preserved(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - targets = [ - ConsumerTarget(repo="org/svc-a", branch="main"), - ConsumerTarget(repo="org/svc-b", branch="develop"), - ] - plan = pub.plan(targets) - assert len(plan.targets) == 2 - - def test_plan_short_hash_is_hex(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - pub = MarketplacePublisher(tmp_path) - plan = pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - int(plan.short_hash, 16) # should not raise - - def test_plan_no_marketplace_yml_raises(self, tmp_path: Path) -> None: - from apm_cli.marketplace.errors import MarketplaceYmlError - - pub = MarketplacePublisher(tmp_path) - with pytest.raises((MarketplaceYmlError, FileNotFoundError, Exception)): - pub.plan([ConsumerTarget(repo="org/svc", branch="main")]) - - -# --------------------------------------------------------------------------- -# Section 8 - MarketplacePublisher.execute + _process_single_target -# --------------------------------------------------------------------------- - - -def _build_runner( - *, - clone_apm_yml: str | None = None, - clone_dir_name: str = "repo", - clone_fail: bool = False, - checkout_fail: bool = False, - commit_fail: bool = False, - push_fail: bool = False, -) -> Any: - """Return a mock runner callable that simulates git operations. - - On clone success it creates the clone dir and optionally writes apm.yml. - """ - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - cwd = kwargs.get("cwd", "") - # Detect the operation by cmd content - if "clone" in cmd: - if clone_fail: - raise subprocess.CalledProcessError( - returncode=128, cmd=cmd, stderr="fatal: repository not found" - ) - clone_path = Path(cwd) / clone_dir_name - clone_path.mkdir(parents=True, exist_ok=True) - if clone_apm_yml is not None: - (clone_path / "apm.yml").write_text(clone_apm_yml, encoding="utf-8") - return _ok_process(cmd) - - if "checkout" in cmd: - if checkout_fail: - raise subprocess.CalledProcessError(returncode=1, cmd=cmd, stderr="error") - return _ok_process(cmd) - - if "commit" in cmd: - if commit_fail: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr="nothing to commit" - ) - return _ok_process(cmd) - - if "push" in cmd: - if push_fail: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr="fatal: push failed" - ) - return _ok_process(cmd) - - return _ok_process(cmd) - - return runner - - -class TestMarketplacePublisherExecute: - """Tests for MarketplacePublisher.execute() and _process_single_target.""" - - def test_execute_returns_list_of_results(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert isinstance(results, list) - assert len(results) == 1 - - def test_execute_happy_path_updated(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_no_change_when_already_at_new_ref(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - already_updated = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v2.0.0 -""" - runner = _build_runner(clone_apm_yml=already_updated) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.NO_CHANGE - - def test_execute_clone_failure_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_fail=True) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_creates_state_file(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - pub.execute(plan, dry_run=True) - state_path = tmp_path / ".apm" / "publish-state.json" - assert state_path.exists() - - def test_execute_dry_run_no_push_called(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - push_called = [] - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - if "push" in cmd: - push_called.append(True) - if "clone" in cmd: - clone_path = Path(kwargs["cwd"]) / "repo" - clone_path.mkdir(parents=True, exist_ok=True) - (clone_path / "apm.yml").write_text(_CONSUMER_APM_YML_WITH_REF, encoding="utf-8") - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - pub.execute(plan, dry_run=True) - assert not push_called, "push should not be called in dry_run mode" - - def test_execute_push_called_when_not_dry_run(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - push_called = [] - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - if "push" in cmd: - push_called.append(True) - if "clone" in cmd: - clone_path = Path(kwargs["cwd"]) / "repo" - clone_path.mkdir(parents=True, exist_ok=True) - (clone_path / "apm.yml").write_text(_CONSUMER_APM_YML_WITH_REF, encoding="utf-8") - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - pub.execute(plan, dry_run=False) - assert push_called, "push should be called when not dry_run" - - def test_execute_missing_apm_yml_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - # Don't create apm.yml in clone dir - runner = _build_runner(clone_apm_yml=None) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - assert "not found" in results[0].message.lower() - - def test_execute_no_dependencies_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_DEPS) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_no_apm_deps_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_APM) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_wrong_marketplace_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WRONG_MARKETPLACE) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_downgrade_guard(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - # Current ref is v3.0.0, new ref is v2.0.0 -> downgrade - newer_yml = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v3.0.0 -""" - runner = _build_runner(clone_apm_yml=newer_yml) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_downgrade=False) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.SKIPPED_DOWNGRADE - - def test_execute_downgrade_allowed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - newer_yml = """\ -name: my-service -dependencies: - apm: - - tool-a@acme-marketplace#v3.0.0 -""" - runner = _build_runner(clone_apm_yml=newer_yml) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_downgrade=True) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_ref_change_implicit_to_explicit(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_ref_change=False) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.SKIPPED_REF_CHANGE - - def test_execute_ref_change_allowed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_NO_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan(allow_ref_change=True) - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_invalid_yaml_in_apm_yml_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml="{not: valid: yaml: [\n") - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_apm_yml_not_a_mapping_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml="- just\n- a\n- list\n") - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_multiple_targets_ordered(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - targets = [ - ConsumerTarget(repo="org/svc-a", branch="main"), - ConsumerTarget(repo="org/svc-b", branch="main"), - ] - plan = _make_plan(targets) - results = pub.execute(plan, dry_run=True) - assert len(results) == 2 - - def test_execute_push_failure_returns_failed(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF, push_fail=True) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=False) - assert results[0].outcome == PublishOutcome.FAILED - - def test_execute_updated_message_contains_old_and_new(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - runner = _build_runner(clone_apm_yml=_CONSUMER_APM_YML_WITH_REF) - pub = MarketplacePublisher(tmp_path, runner=runner) - plan = _make_plan() - results = pub.execute(plan, dry_run=True) - assert results[0].outcome == PublishOutcome.UPDATED - assert "v1.0.0" in results[0].message or "acme-marketplace" in results[0].message - - -# --------------------------------------------------------------------------- -# Section 9 - MarketplacePublisher.safe_force_push (publisher.py) -# --------------------------------------------------------------------------- - - -class TestSafeForPush: - """Tests for MarketplacePublisher.safe_force_push.""" - - def test_trailer_match_returns_true(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - expected_trailer = "abc12345" - commit_msg = f"chore: update\n\nAPM-Publish-Id: {expected_trailer}" - call_count = [] - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - call_count.append(cmd) - if "log" in cmd: - return subprocess.CompletedProcess(cmd, returncode=0, stdout=commit_msg, stderr="") - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - result = pub.safe_force_push("origin", "apm/test", expected_trailer) - assert result is True - - def test_trailer_mismatch_returns_false(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - if "log" in cmd: - return subprocess.CompletedProcess( - cmd, returncode=0, stdout="chore: no trailer", stderr="" - ) - return _ok_process(cmd) - - pub = MarketplacePublisher(tmp_path, runner=runner) - result = pub.safe_force_push("origin", "apm/test", "deadbeef") - assert result is False - - def test_exception_returns_false(self, tmp_path: Path) -> None: - _make_mkt_root(tmp_path) - - def runner(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - raise subprocess.CalledProcessError(returncode=128, cmd=cmd, stderr="error") - - pub = MarketplacePublisher(tmp_path, runner=runner) - result = pub.safe_force_push("origin", "apm/test", "deadbeef") - assert result is False - - -# --------------------------------------------------------------------------- -# Section 10 - CLI commands via CliRunner -# --------------------------------------------------------------------------- - - -def _mock_source(name: str = "acme-tools") -> MarketplaceSource: - return MarketplaceSource( - name=name, owner="acme", repo="tools", branch="main", host="github.com" - ) - - -class TestListCommand: - """Tests for `apm marketplace list`.""" - - def test_empty_registry_shows_info(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[], - ): - result = runner.invoke(list_cmd, [], catch_exceptions=False) - assert result.exit_code == 0 - assert "No marketplaces" in result.output or "registered" in result.output.lower() - - def test_with_sources_shows_them(self) -> None: - runner = CliRunner() - source = _mock_source() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[source], - ): - result = runner.invoke(list_cmd, [], catch_exceptions=False) - assert result.exit_code == 0 - assert "acme-tools" in result.output or "acme" in result.output - - def test_no_traceback(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[], - ): - result = runner.invoke(list_cmd, [], catch_exceptions=False) - assert "Traceback" not in result.output - - -class TestRemoveCommand: - """Tests for `apm marketplace remove`.""" - - def test_success_with_yes_flag(self) -> None: - runner = CliRunner() - source = _mock_source() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch("apm_cli.marketplace.registry.remove_marketplace"), - patch("apm_cli.marketplace.client.clear_marketplace_cache"), - ): - result = runner.invoke(remove, ["acme-tools", "--yes"], catch_exceptions=False) - assert result.exit_code == 0 - assert "acme-tools" in result.output or "removed" in result.output.lower() - - def test_non_interactive_without_yes_exits_1(self) -> None: - runner = CliRunner() - source = _mock_source() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.commands._helpers._is_interactive", - return_value=False, - ), - ): - result = runner.invoke(remove, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_not_found_exits_1(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - side_effect=MarketplaceNotFoundError("acme-tools"), - ): - result = runner.invoke(remove, ["acme-tools", "--yes"], catch_exceptions=False) - assert result.exit_code == 1 - - -class TestUpdateCommand: - """Tests for `apm marketplace update`.""" - - def test_update_specific_name(self) -> None: - runner = CliRunner() - source = _mock_source() - manifest = _make_manifest() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch("apm_cli.marketplace.client.clear_marketplace_cache"), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(update, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "acme-tools" in result.output or "updated" in result.output.lower() - - def test_update_all_empty_registry(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=[], - ): - result = runner.invoke(update, [], catch_exceptions=False) - assert result.exit_code == 0 - assert "No marketplaces" in result.output or "registered" in result.output.lower() - - def test_update_all_multiple_sources(self) -> None: - runner = CliRunner() - sources = [_mock_source("mkt-a"), _mock_source("mkt-b")] - manifest = _make_manifest() - with ( - patch( - "apm_cli.marketplace.registry.get_registered_marketplaces", - return_value=sources, - ), - patch("apm_cli.marketplace.client.clear_marketplace_cache"), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(update, [], catch_exceptions=False) - assert result.exit_code == 0 - - -class TestBrowseCommand: - """Tests for `apm marketplace browse`.""" - - def test_browse_with_plugins(self) -> None: - runner = CliRunner() - source = _mock_source() - manifest = _make_manifest() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(browse, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "plugin-a" in result.output or "plugin" in result.output.lower() - - def test_browse_empty_plugins(self) -> None: - runner = CliRunner() - source = _mock_source() - manifest = _make_manifest(plugins=[]) - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.fetch_marketplace", - return_value=manifest, - ), - ): - result = runner.invoke(browse, ["acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "no plugins" in result.output.lower() or "0" in result.output - - def test_browse_not_found_exits_1(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - side_effect=Exception("not found"), - ): - result = runner.invoke(browse, ["nonexistent"], catch_exceptions=False) - assert result.exit_code == 1 - - -class TestSearchCommand: - """Tests for `apm marketplace search`.""" - - def test_search_missing_at_sign_exits_1(self) -> None: - runner = CliRunner() - result = runner.invoke(search, ["noseparator"], catch_exceptions=False) - assert result.exit_code == 1 - assert "@" in result.output - - def test_search_empty_query_exits_1(self) -> None: - runner = CliRunner() - result = runner.invoke(search, ["@acme-tools"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_search_empty_marketplace_exits_1(self) -> None: - runner = CliRunner() - result = runner.invoke(search, ["query@"], catch_exceptions=False) - assert result.exit_code == 1 - - def test_search_unregistered_marketplace_exits_1(self) -> None: - runner = CliRunner() - with patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - side_effect=MarketplaceNotFoundError("unknown"), - ): - result = runner.invoke(search, ["security@unknown"], catch_exceptions=False) - assert result.exit_code == 1 - assert "not registered" in result.output.lower() or "unknown" in result.output - - def test_search_no_results(self) -> None: - runner = CliRunner() - source = _mock_source() - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.search_marketplace", - return_value=[], - ), - ): - result = runner.invoke(search, ["xyzzy@acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "no plugins" in result.output.lower() or "not found" in result.output.lower() - - def test_search_with_results(self) -> None: - runner = CliRunner() - source = _mock_source() - plugins = [MarketplacePlugin(name="security-scanner", description="Security tool")] - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.search_marketplace", - return_value=plugins, - ), - ): - result = runner.invoke(search, ["security@acme-tools"], catch_exceptions=False) - assert result.exit_code == 0 - assert "security-scanner" in result.output or "security" in result.output.lower() - - def test_search_respects_limit(self) -> None: - runner = CliRunner() - source = _mock_source() - plugins = [MarketplacePlugin(name=f"p{i}") for i in range(25)] - with ( - patch( - "apm_cli.marketplace.registry.get_marketplace_by_name", - return_value=source, - ), - patch( - "apm_cli.marketplace.client.search_marketplace", - return_value=plugins, - ), - ): - result = runner.invoke( - search, ["query@acme-tools", "--limit", "5"], catch_exceptions=False - ) - assert result.exit_code == 0 - - -class TestMarketplaceGroupBuildDeprecated: - """Tests that 'apm marketplace build' is rejected with a clear error.""" - - def test_build_raises_usage_error(self) -> None: - runner = CliRunner() - result = runner.invoke(marketplace, ["build"], catch_exceptions=False) - # Should exit with non-zero or raise a UsageError message - assert result.exit_code != 0 or "build" in result.output.lower() - # The error message should mention 'apm pack' - assert "pack" in result.output or "removed" in result.output - - -# --------------------------------------------------------------------------- -# Section 11 - PublishPlan field assertions -# --------------------------------------------------------------------------- - - -class TestPublishPlan: - """Tests for PublishPlan dataclass fields.""" - - def test_publish_plan_fields(self) -> None: - targets = (ConsumerTarget(repo="org/svc", branch="main"),) - plan = PublishPlan( - marketplace_name="test-mkt", - marketplace_version="1.0.0", - targets=targets, - commit_message="chore: bump", - branch_name="apm/test", - new_ref="v1.0.0", - tag_pattern_used="v{version}", - short_hash="abcd1234", - ) - assert plan.marketplace_name == "test-mkt" - assert plan.marketplace_version == "1.0.0" - assert len(plan.targets) == 1 - assert plan.allow_downgrade is False - assert plan.allow_ref_change is False - assert plan.target_package is None - - def test_publish_plan_with_target_package(self) -> None: - plan = PublishPlan( - marketplace_name="test", - marketplace_version="1.0.0", - targets=(ConsumerTarget(repo="org/svc", branch="main"),), - commit_message="msg", - branch_name="apm/test", - new_ref="v1.0.0", - tag_pattern_used="v{version}", - target_package="tool-a", - ) - assert plan.target_package == "tool-a" - - -# --------------------------------------------------------------------------- -# Section 12 - PublishOutcome enum -# --------------------------------------------------------------------------- - - -class TestPublishOutcomeEnum: - """Tests for PublishOutcome enum values.""" - - def test_all_values_unique(self) -> None: - values = [o.value for o in PublishOutcome] - assert len(values) == len(set(values)) - - def test_string_subclass(self) -> None: - assert isinstance(PublishOutcome.UPDATED.value, str) - - def test_updated_value(self) -> None: - assert PublishOutcome.UPDATED.value == "updated" - - def test_failed_value(self) -> None: - assert PublishOutcome.FAILED.value == "failed" - - def test_no_change_value(self) -> None: - assert PublishOutcome.NO_CHANGE.value == "no-change" - - -# --------------------------------------------------------------------------- -# Section 13 - _check_gitignore_for_marketplace_json -# --------------------------------------------------------------------------- - - -class TestCheckGitignoreForMarketplaceJson: - """Tests for _check_gitignore_for_marketplace_json (via CliRunner CWD).""" - - def test_no_gitignore_no_warning(self, tmp_path: Path) -> None: - """No .gitignore file should not produce a warning.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_not_called() - - def test_gitignore_with_matching_pattern_warns(self, tmp_path: Path) -> None: - """A .gitignore line matching 'marketplace.json' should warn.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - (tmp_path / ".gitignore").write_text("marketplace.json\n", encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_called_once() - - def test_gitignore_comment_line_ignored(self, tmp_path: Path) -> None: - """Comment lines in .gitignore should not trigger a warning.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - (tmp_path / ".gitignore").write_text("# marketplace.json\n", encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_not_called() - - def test_gitignore_json_wildcard_warns(self, tmp_path: Path) -> None: - """A *.json rule in .gitignore should warn.""" - from apm_cli.commands.marketplace import _check_gitignore_for_marketplace_json - - (tmp_path / ".gitignore").write_text("*.json\n", encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - _check_gitignore_for_marketplace_json(logger_mock) - finally: - os.chdir(old_cwd) - logger_mock.warning.assert_called_once() - - -# --------------------------------------------------------------------------- -# Section 14 - _load_yml_or_exit (indirectly via CWD + sys.exit mocking) -# --------------------------------------------------------------------------- - - -class TestLoadYmlOrExit: - """Tests for _load_yml_or_exit helper.""" - - def test_missing_yml_exits_1(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_yml_or_exit - - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - with pytest.raises(SystemExit) as exc_info: - _load_yml_or_exit(logger_mock) - finally: - os.chdir(old_cwd) - assert exc_info.value.code == 1 - - def test_valid_yml_returns_config(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_yml_or_exit - - (tmp_path / "marketplace.yml").write_text(_MARKETPLACE_YML, encoding="utf-8") - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - yml = _load_yml_or_exit(logger_mock) - finally: - os.chdir(old_cwd) - assert yml.name == "acme-marketplace" - - def test_invalid_yml_exits_2(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_yml_or_exit - - (tmp_path / "marketplace.yml").write_text( - "name: ~\nversion: not-semver\n", encoding="utf-8" - ) - logger_mock = MagicMock() - import os - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - with pytest.raises(SystemExit) as exc_info: - _load_yml_or_exit(logger_mock) - finally: - os.chdir(old_cwd) - # Schema errors may produce exit code 1 or 2 - assert exc_info.value.code in (1, 2) diff --git a/tests/integration/test_wave3_marketplace_coverage.py b/tests/integration/test_wave3_marketplace_coverage.py index 0ddaf9499..42b8a9837 100644 --- a/tests/integration/test_wave3_marketplace_coverage.py +++ b/tests/integration/test_wave3_marketplace_coverage.py @@ -4,7 +4,6 @@ 1. src/apm_cli/commands/marketplace/__init__.py (422 miss, 37%) 2. src/apm_cli/commands/init.py (188 miss, 33%) 3. src/apm_cli/commands/pack.py (188 miss, 41%) -4. src/apm_cli/marketplace/publisher.py (217 miss, 25%) 5. src/apm_cli/core/script_runner.py (254 miss, 39%) Strategy: @@ -530,36 +529,6 @@ def test_script_runner_color_modes(self) -> None: # --------------------------------------------------------------------------- -# Marketplace Publisher Tests (src/apm_cli/marketplace/publisher.py) -# --------------------------------------------------------------------------- - - -class TestMarketplacePublisher: - """Test coverage for MarketplacePublisher class.""" - - def test_publisher_initialization(self, tmp_path: Path) -> None: - """Test MarketplacePublisher initializes with marketplace_root.""" - from apm_cli.marketplace.publisher import MarketplacePublisher - - # Create a marketplace root directory - mp_root = tmp_path / "marketplace" - mp_root.mkdir() - publisher = MarketplacePublisher(marketplace_root=mp_root) - assert publisher is not None - - def test_publisher_sanitize_branch_name(self) -> None: - """Test publisher branch name sanitization.""" - from apm_cli.marketplace.publisher import _sanitise_branch_segment - - # Test special characters are replaced - result = _sanitise_branch_segment("test@azure#v1.0.0") - assert "@" not in result - assert "#" not in result - assert "-" in result - - -# --------------------------------------------------------------------------- -# Integration: Combined Command Tests # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/test_doctor.py b/tests/unit/commands/test_doctor.py index 38c98a7cc..cabf7ce9e 100644 --- a/tests/unit/commands/test_doctor.py +++ b/tests/unit/commands/test_doctor.py @@ -8,7 +8,6 @@ from click.testing import CliRunner from apm_cli.cli import cli -from apm_cli.commands.marketplace import marketplace # Token env vars that AuthResolver inspects. Cleared so the doctor's auth # check is deterministic regardless of the host environment. @@ -57,29 +56,14 @@ def test_common_workflows_footer_present(): assert "apm doctor" in result.output -def test_marketplace_doctor_hidden_from_help(): - """Legacy `apm marketplace doctor` must not appear in marketplace --help.""" +def test_marketplace_doctor_not_available(): + """``apm marketplace doctor`` must not be a registered subcommand.""" runner = CliRunner() result = runner.invoke(cli, ["marketplace", "--help"]) assert result.exit_code == 0 - # 'doctor' as a subcommand listing should be gone from the Authoring - # commands block now that it has been promoted to top-level. assert "doctor " not in result.output # column-aligned listing -def test_marketplace_doctor_still_works_with_deprecation_hint(mock_subprocess_success): - """Legacy invocation must keep working and print the migration hint.""" - runner = CliRunner() - result = runner.invoke(marketplace, ["doctor"]) - # The deprecation hint is emitted with err=True. Click 8.2 separates - # stdout/stderr by default, so check both to stay version-agnostic. - combined = (result.output or "") + (getattr(result, "stderr", "") or "") - assert "deprecated" in combined.lower() - assert "apm doctor" in combined - # And the diagnostics still run. - assert result.exit_code in (0, 1) # 1 if network unreachable in sandbox - - def test_apm_doctor_runs_diagnostics(mock_subprocess_success): """Top-level invocation should produce the diagnostics table.""" runner = CliRunner() diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py index d746934cc..c6aeb54ad 100644 --- a/tests/unit/commands/test_marketplace_doctor.py +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -1,4 +1,4 @@ -"""Tests for ``apm marketplace doctor`` subcommand.""" +"""Tests for ``apm doctor`` command (diagnostics).""" from __future__ import annotations @@ -10,7 +10,7 @@ import pytest from click.testing import CliRunner -from apm_cli.commands.marketplace import marketplace +from apm_cli.cli import cli from apm_cli.marketplace.yml_schema import ( MarketplaceOwner, MarketplaceYml, @@ -73,11 +73,6 @@ def _make_run_result(returncode=0, stdout="", stderr=""): ) -_GH_OK = _make_run_result( - 0, stdout="gh version 2.50.0 (2024-06-01)\nhttps://github.com/cli/cli/releases/tag/v2.50.0" -) - - # --------------------------------------------------------------------------- # All checks pass # --------------------------------------------------------------------------- @@ -93,10 +88,9 @@ def test_all_pass_exit_0(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0, stdout="abc123\tHEAD"), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -105,10 +99,9 @@ def test_git_version_shown(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0, stdout="abc123\tHEAD"), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "git version" in result.output @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -117,10 +110,9 @@ def test_network_reachable_shown(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "reachable" in result.output.lower() @@ -135,7 +127,7 @@ def test_git_missing_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = FileNotFoundError("git not found") - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 assert "not found" in result.output.lower() @@ -144,7 +136,7 @@ def test_git_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 assert "timed out" in result.output.lower() @@ -154,10 +146,9 @@ def test_git_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(returncode=1, stderr="error"), _make_run_result(0), # network check may still run - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 @@ -173,10 +164,9 @@ def test_network_failure_exits_1(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(128, stderr="fatal: could not resolve host"), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -185,10 +175,9 @@ def test_network_timeout(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), subprocess.TimeoutExpired(cmd="git", timeout=5), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 assert "timed out" in result.output.lower() @@ -198,10 +187,9 @@ def test_network_auth_error(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(128, stderr="fatal: authentication failed"), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 @@ -218,10 +206,9 @@ def test_github_token_detected(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "Token detected" in result.output # Must NOT print the actual token assert "ghp_test123" not in result.output @@ -234,10 +221,9 @@ def test_gh_token_detected(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "Token detected" in result.output assert "gho_test456" not in result.output @@ -253,104 +239,15 @@ def test_no_token_informational(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 # no token is informational, not a failure assert "unauthenticated" in result.output.lower() or "rate limit" in result.output.lower() # --------------------------------------------------------------------------- -# Check 4: gh CLI -# --------------------------------------------------------------------------- - - -class TestDoctorGhCliCheck: - @patch("apm_cli.commands.marketplace.doctor.subprocess.run") - def test_gh_found_shows_version(self, mock_run, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - mock_run.side_effect = [ - _make_run_result(0, stdout="git version 2.40.0"), - _make_run_result(0), - _make_run_result( - 0, - stdout="gh version 2.50.0 (2024-06-01)\nhttps://github.com/cli/cli/releases/tag/v2.50.0", - ), - ] - - result = runner.invoke(marketplace, ["doctor"]) - assert result.exit_code == 0 - assert "gh version" in result.output - - @patch("apm_cli.commands.marketplace.doctor.subprocess.run") - def test_gh_missing_is_warning_not_error(self, mock_run, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - mock_run.side_effect = [ - _make_run_result(0, stdout="git version 2.40.0"), - _make_run_result(0), - FileNotFoundError("gh not found"), - ] - - result = runner.invoke(marketplace, ["doctor"]) - assert result.exit_code == 0 # gh is informational; missing does not fail - assert "not found" in result.output.lower() - assert "cli.github.com" in result.output - - @patch("apm_cli.commands.marketplace.doctor.subprocess.run") - def test_gh_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - mock_run.side_effect = [ - _make_run_result(0, stdout="git version 2.40.0"), - _make_run_result(0), - _make_run_result(returncode=1, stderr="error"), - ] - - result = runner.invoke(marketplace, ["doctor"]) - assert result.exit_code == 0 # informational - assert "non-zero" in result.output.lower() - - @patch("apm_cli.commands.marketplace.doctor.subprocess.run") - def test_gh_timeout(self, mock_run, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - mock_run.side_effect = [ - _make_run_result(0, stdout="git version 2.40.0"), - _make_run_result(0), - subprocess.TimeoutExpired(cmd="gh", timeout=10), - ] - - result = runner.invoke(marketplace, ["doctor"]) - assert result.exit_code == 0 # informational - assert "timed out" in result.output.lower() - - @patch("apm_cli.commands.marketplace.doctor.subprocess.run") - def test_gh_general_exception(self, mock_run, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - mock_run.side_effect = [ - _make_run_result(0, stdout="git version 2.40.0"), - _make_run_result(0), - OSError("Permission denied"), - ] - - result = runner.invoke(marketplace, ["doctor"]) - assert result.exit_code == 0 # informational - assert "Permission denied" in result.output - - @patch("apm_cli.commands.marketplace.doctor.subprocess.run") - def test_gh_shown_in_table(self, mock_run, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - mock_run.side_effect = [ - _make_run_result(0, stdout="git version 2.40.0"), - _make_run_result(0), - _GH_OK, - ] - - result = runner.invoke(marketplace, ["doctor"]) - assert "gh cli" in result.output.lower() - - -# --------------------------------------------------------------------------- -# Check 5: marketplace.yml +# Check 4: marketplace.yml # --------------------------------------------------------------------------- @@ -362,10 +259,9 @@ def test_yml_present_and_valid(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "valid" in result.output.lower() or "found" in result.output.lower() @@ -376,10 +272,9 @@ def test_yml_present_but_invalid(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) # yml check is informational; critical checks still pass assert result.exit_code == 0 assert "error" in result.output.lower() @@ -390,10 +285,9 @@ def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "No marketplace authoring config" in result.output @@ -445,10 +339,9 @@ def test_partial_coverage_lists_missing_formats(self, mock_run, runner, tmp_path mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "format coverage" in result.output # Surfaces what's configured and what's available @@ -462,10 +355,9 @@ def test_full_coverage_passes_silently(self, mock_run, runner, tmp_path, monkeyp mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "format coverage" in result.output assert "all known formats" in result.output.lower() @@ -479,10 +371,9 @@ def test_no_config_skips_format_coverage_row(self, mock_run, runner, tmp_path, m mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "format coverage" not in result.output @@ -501,10 +392,9 @@ def test_yml_invalid_does_not_cause_exit_1(self, mock_run, runner, tmp_path, mon mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -513,7 +403,7 @@ def test_git_fail_plus_valid_yml_exits_1(self, mock_run, runner, tmp_path, monke (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") mock_run.side_effect = FileNotFoundError("git not found") - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 @@ -529,10 +419,9 @@ def test_verbose_no_crash(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor", "--verbose"]) + result = runner.invoke(cli, ["doctor", "--verbose"]) assert result.exit_code == 0 @@ -548,10 +437,9 @@ def test_table_has_check_column(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) # Table should mention the check names assert "git" in result.output.lower() assert "network" in result.output.lower() @@ -565,10 +453,9 @@ def test_info_icon_for_auth(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "[i]" in result.output @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -577,10 +464,9 @@ def test_pass_icon_for_git(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "[+]" in result.output @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -588,7 +474,7 @@ def test_fail_icon_for_git_missing(self, mock_run, runner, tmp_path, monkeypatch monkeypatch.chdir(tmp_path) mock_run.side_effect = FileNotFoundError("not found") - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "[x]" in result.output @@ -603,7 +489,7 @@ def test_general_exception_in_git_check(self, mock_run, runner, tmp_path, monkey monkeypatch.chdir(tmp_path) mock_run.side_effect = OSError("Permission denied") - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 assert "Permission denied" in result.output @@ -614,10 +500,9 @@ def test_git_ok_network_file_not_found(self, mock_run, runner, tmp_path, monkeyp mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), FileNotFoundError("git not found"), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 1 @@ -644,7 +529,6 @@ def test_duplicate_names_flagged( mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] mock_load.return_value = MarketplaceYml( name="test", @@ -667,7 +551,7 @@ def test_duplicate_names_flagged( ), ) - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "duplicate" in result.output.lower() assert "learning" in result.output @@ -686,7 +570,6 @@ def test_no_duplicate_names_shows_pass( mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] mock_load.return_value = MarketplaceYml( name="test", @@ -707,7 +590,7 @@ def test_no_duplicate_names_shows_pass( ), ) - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "No duplicate package names" in result.output @@ -724,10 +607,9 @@ def test_no_duplicate_check_when_yml_absent( mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "duplicate" not in result.output.lower() @@ -804,9 +686,8 @@ def test_aligned_row_rendered(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "version alignment" in result.output.lower() assert "lockstep" in result.output.lower() assert "1/1" in result.output or "aligned" in result.output.lower() @@ -818,9 +699,8 @@ def test_misaligned_row_rendered(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "version alignment" in result.output.lower() assert "misaligned" in result.output.lower() @@ -831,9 +711,8 @@ def test_skipped_when_no_marketplace_block(self, mock_run, runner, tmp_path, mon mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "version alignment" not in result.output.lower() @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -843,9 +722,8 @@ def test_strategy_label_visible(self, mock_run, runner, tmp_path, monkeypatch): mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert "tag_pattern" in result.output.lower() @patch("apm_cli.commands.marketplace.doctor.subprocess.run") @@ -856,9 +734,8 @@ def test_misaligned_does_not_fail_critical_exit(self, mock_run, runner, tmp_path mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) # Doctor exit is governed only by critical checks (1-2). assert result.exit_code == 0 @@ -880,9 +757,8 @@ def test_auth_exception_shows_no_token( mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) assert result.exit_code == 0 assert "No token" in result.output or "unauthenticated" in result.output.lower() @@ -902,8 +778,7 @@ def test_apm_yml_marketplace_error_logged(self, mock_run, runner, tmp_path, monk mock_run.side_effect = [ _make_run_result(0, stdout="git version 2.40.0"), _make_run_result(0), - _GH_OK, ] - result = runner.invoke(marketplace, ["doctor"]) + result = runner.invoke(cli, ["doctor"]) # Either passes or fails gracefully assert result.exit_code in (0, 1, 2) diff --git a/tests/unit/commands/test_marketplace_publish.py b/tests/unit/commands/test_marketplace_publish.py deleted file mode 100644 index 00f3f48d3..000000000 --- a/tests/unit/commands/test_marketplace_publish.py +++ /dev/null @@ -1,984 +0,0 @@ -"""Tests for ``apm marketplace publish`` subcommand.""" - -from __future__ import annotations - -import json -import textwrap -from pathlib import Path # noqa: F401 -from unittest.mock import MagicMock, call, patch # noqa: F401 - -import pytest -from click.testing import CliRunner - -from apm_cli.commands.marketplace import marketplace -from apm_cli.marketplace.pr_integration import PrResult, PrState -from apm_cli.marketplace.publisher import ( - ConsumerTarget, - PublishOutcome, - PublishPlan, - TargetResult, -) - -# --------------------------------------------------------------------------- -# Shared fixtures and helpers -# --------------------------------------------------------------------------- - -_BASIC_YML = textwrap.dedent("""\ - name: test-marketplace - description: Test marketplace - version: 2.0.0 - owner: - name: Test Owner - packages: - - name: pkg-alpha - source: acme-org/pkg-alpha - version: "^1.0.0" -""") - -_TARGETS_YML = textwrap.dedent("""\ - targets: - - repo: acme-org/service-a - branch: main - - repo: acme-org/service-b - branch: develop -""") - -_MARKETPLACE_JSON = json.dumps( - { - "name": "test-marketplace", - "plugins": [], - } -) - - -def _fake_plan(targets=None): - """Build a fake ``PublishPlan``.""" - if targets is None: - targets = ( - ConsumerTarget(repo="acme-org/service-a", branch="main"), - ConsumerTarget(repo="acme-org/service-b", branch="develop"), - ) - return PublishPlan( - marketplace_name="test-marketplace", - marketplace_version="2.0.0", - targets=targets, - commit_message="chore(apm): bump test-marketplace to 2.0.0", - branch_name="apm/marketplace-update-test-marketplace-2.0.0-abcd1234", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - short_hash="abcd1234", - ) - - -def _fake_result(target, outcome=PublishOutcome.UPDATED, message="OK"): - """Build a fake ``TargetResult``.""" - return TargetResult( - target=target, - outcome=outcome, - message=message, - old_version="v1.0.0", - new_version="v2.0.0", - ) - - -def _fake_pr_result(target, state=PrState.OPENED, pr_number=42, pr_url=None): - """Build a fake ``PrResult``.""" - url = pr_url or f"https://github.com/{target.repo}/pull/{pr_number}" - return PrResult( - target=target, - state=state, - pr_number=pr_number, - pr_url=url, - message="PR opened.", - ) - - -def _write_fixtures(tmp_path, *, targets_yml=_TARGETS_YML, yml=_BASIC_YML): - """Write marketplace.yml, marketplace.json, and consumer-targets.yml.""" - (tmp_path / "marketplace.yml").write_text(yml, encoding="utf-8") - (tmp_path / "marketplace.json").write_text(_MARKETPLACE_JSON, encoding="utf-8") - (tmp_path / "consumer-targets.yml").write_text(targets_yml, encoding="utf-8") - - -@pytest.fixture -def runner(): - return CliRunner() - - -# --------------------------------------------------------------------------- -# Happy path -# --------------------------------------------------------------------------- - - -class TestPublishHappyPath: - """Happy path: publish to 2 targets with PRs opened.""" - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_happy_path_exit_0(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.side_effect = [ - _fake_pr_result(targets[0], pr_number=10), - _fake_pr_result(targets[1], pr_number=11), - ] - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 0, result.output - assert "Published 2/2 targets" in result.output - assert "publish-state.json" in result.output - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_pr_integrator_called_for_updated_targets( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.side_effect = [ - _fake_pr_result(targets[0]), - _fake_pr_result(targets[1]), - ] - - runner.invoke(marketplace, ["publish", "--yes"]) - assert mock_pr.open_or_update.call_count == 2 - - -# --------------------------------------------------------------------------- -# --no-pr flag -# --------------------------------------------------------------------------- - - -class TestPublishNoPr: - """--no-pr: publisher runs but PR integrator is not called.""" - - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_no_pr_skips_pr_integration(self, MockPublisher, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - with patch("apm_cli.commands.marketplace.publish.PrIntegrator") as MockPr: - result = runner.invoke(marketplace, ["publish", "--yes", "--no-pr"]) - assert result.exit_code == 0, result.output - # PrIntegrator should not have been instantiated for operations - mock_pr = MockPr.return_value - mock_pr.open_or_update.assert_not_called() - - -# --------------------------------------------------------------------------- -# --dry-run -# --------------------------------------------------------------------------- - - -class TestPublishDryRun: - """--dry-run: publisher.execute with dry_run=True, PR with dry_run=True.""" - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_dry_run_passes_flag_to_execute( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(targets[0]) - - result = runner.invoke(marketplace, ["publish", "--yes", "--dry-run"]) - assert result.exit_code == 0, result.output - - # Verify dry_run=True was passed to execute - mock_pub.execute.assert_called_once_with( - plan, - dry_run=True, - parallel=4, - ) - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_dry_run_passes_flag_to_pr_integration( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(targets[0]) - - runner.invoke(marketplace, ["publish", "--yes", "--dry-run"]) - - # Verify dry_run=True was passed to pr.open_or_update - for c in mock_pr.open_or_update.call_args_list: - assert c.kwargs.get("dry_run") is True or c[1].get("dry_run") is True - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_dry_run_shows_info_note(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - result = runner.invoke(marketplace, ["publish", "--yes", "--dry-run"]) - assert "dry-run" in result.output.lower() or "Dry run" in result.output - - -# --------------------------------------------------------------------------- -# Missing files -# --------------------------------------------------------------------------- - - -class TestPublishMissingFiles: - def test_missing_marketplace_yml_exit_2(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "marketplace.json").write_text("{}", encoding="utf-8") - (tmp_path / "consumer-targets.yml").write_text(_TARGETS_YML, encoding="utf-8") - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 # _load_yml_or_exit calls sys.exit(1) on missing file - - def test_missing_marketplace_json_exit_1(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") - (tmp_path / "consumer-targets.yml").write_text(_TARGETS_YML, encoding="utf-8") - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "marketplace.json not found" in result.output - assert "apm pack" in result.output - - def test_marketplace_yml_schema_error_exit_2(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - bad_yml = "name: test\n" # missing required fields - (tmp_path / "marketplace.yml").write_text(bad_yml, encoding="utf-8") - (tmp_path / "marketplace.json").write_text("{}", encoding="utf-8") - (tmp_path / "consumer-targets.yml").write_text(_TARGETS_YML, encoding="utf-8") - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 2 - - -class TestPublishMissingTargets: - def test_missing_targets_file_exit_1_with_guidance(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") - (tmp_path / "marketplace.json").write_text(_MARKETPLACE_JSON, encoding="utf-8") - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "consumer-targets.yml" in result.output - assert "--targets" in result.output - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_explicit_targets_file(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") - (tmp_path / "marketplace.json").write_text(_MARKETPLACE_JSON, encoding="utf-8") - custom_targets = tmp_path / "custom-targets.yml" - custom_targets.write_text(_TARGETS_YML, encoding="utf-8") - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - result = runner.invoke( - marketplace, - ["publish", "--yes", "--targets", str(custom_targets)], - ) - assert result.exit_code == 0, result.output - - def test_explicit_targets_file_not_found(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") - (tmp_path / "marketplace.json").write_text(_MARKETPLACE_JSON, encoding="utf-8") - - result = runner.invoke( - marketplace, - ["publish", "--yes", "--targets", "/nonexistent/file.yml"], - ) - assert result.exit_code == 1 - assert "not found" in result.output.lower() - - -# --------------------------------------------------------------------------- -# Invalid targets -# --------------------------------------------------------------------------- - - -class TestPublishInvalidTargets: - def test_target_missing_repo_key(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures( - tmp_path, - targets_yml="targets:\n - branch: main\n", - ) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "repo" in result.output.lower() - - def test_path_unsafe_path_in_repo(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - unsafe_targets = textwrap.dedent("""\ - targets: - - repo: acme-org/service-a - branch: main - path_in_repo: ../etc/passwd - """) - _write_fixtures(tmp_path, targets_yml=unsafe_targets) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - - def test_target_missing_branch(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - no_branch = textwrap.dedent("""\ - targets: - - repo: acme-org/service-a - """) - _write_fixtures(tmp_path, targets_yml=no_branch) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "branch" in result.output.lower() - - -# --------------------------------------------------------------------------- -# gh availability -# --------------------------------------------------------------------------- - - -class TestPublishGhAvailability: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - def test_gh_not_available_exit_1(self, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = ( - False, - "gh CLI not found on PATH. Install from https://cli.github.com/ or pass --no-pr.", - ) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "gh" in result.output.lower() - - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_gh_not_available_but_no_pr_proceeds( - self, MockPublisher, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - # PrIntegrator should not even be instantiated for check_available - result = runner.invoke(marketplace, ["publish", "--yes", "--no-pr"]) - assert result.exit_code == 0, result.output - - -# --------------------------------------------------------------------------- -# TTY / interactive behaviour -# --------------------------------------------------------------------------- - - -class TestPublishInteractive: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=False) - def test_non_tty_without_yes_exit_1( - self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - - result = runner.invoke(marketplace, ["publish"]) - assert result.exit_code == 1 - assert "Non-interactive session" in result.output - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=False) - def test_non_tty_with_yes_proceeds( - self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 0, result.output - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=True) - def test_tty_user_types_n_aborts_gracefully( - self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - - result = runner.invoke(marketplace, ["publish"], input="n\n") - assert result.exit_code == 0 - assert "cancelled" in result.output.lower() - mock_pub.execute.assert_not_called() - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=True) - def test_tty_user_types_y_proceeds( - self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(targets[0]) - - result = runner.invoke(marketplace, ["publish"], input="y\n") - assert result.exit_code == 0, result.output - mock_pub.execute.assert_called_once() - - -# --------------------------------------------------------------------------- -# --draft flag -# --------------------------------------------------------------------------- - - -class TestPublishDraft: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_draft_passed_to_pr_integrator( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0]), - _fake_result(targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(targets[0]) - - runner.invoke(marketplace, ["publish", "--yes", "--draft"]) - - for c in mock_pr.open_or_update.call_args_list: - assert c.kwargs.get("draft") is True - - -# --------------------------------------------------------------------------- -# --allow-downgrade and --allow-ref-change -# --------------------------------------------------------------------------- - - -class TestPublishPlanFlags: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_allow_downgrade_passed_to_plan( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - runner.invoke(marketplace, ["publish", "--yes", "--allow-downgrade"]) - - _, kwargs = mock_pub.plan.call_args - assert kwargs.get("allow_downgrade") is True - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_allow_ref_change_passed_to_plan( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - runner.invoke(marketplace, ["publish", "--yes", "--allow-ref-change"]) - - _, kwargs = mock_pub.plan.call_args - assert kwargs.get("allow_ref_change") is True - - -# --------------------------------------------------------------------------- -# --parallel -# --------------------------------------------------------------------------- - - -class TestPublishParallel: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_parallel_passed_to_execute(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - runner.invoke(marketplace, ["publish", "--yes", "--parallel", "2"]) - - mock_pub.execute.assert_called_once_with( - plan, - dry_run=False, - parallel=2, - ) - - -# --------------------------------------------------------------------------- -# Mixed outcomes -# --------------------------------------------------------------------------- - - -class TestPublishMixedOutcomes: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_mixed_outcomes_exit_1(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - - targets_yml = textwrap.dedent("""\ - targets: - - repo: acme-org/service-a - branch: main - - repo: acme-org/service-b - branch: develop - - repo: acme-org/service-c - branch: main - """) - _write_fixtures(tmp_path, targets_yml=targets_yml) - - t_a = ConsumerTarget(repo="acme-org/service-a", branch="main") - t_b = ConsumerTarget(repo="acme-org/service-b", branch="develop") - t_c = ConsumerTarget(repo="acme-org/service-c", branch="main") - plan = _fake_plan(targets=(t_a, t_b, t_c)) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(t_a, PublishOutcome.UPDATED, "Updated"), - _fake_result(t_b, PublishOutcome.SKIPPED_DOWNGRADE, "Downgrade"), - _fake_result(t_c, PublishOutcome.FAILED, "Clone failed"), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(t_a) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "1/3 targets" in result.output or "Published" in result.output - # Verify all repos mentioned in output - assert "acme-org/service-a" in result.output - assert "acme-org/service-b" in result.output - assert "acme-org/service-c" in result.output - - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_summary_table_has_all_outcomes( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - - targets_yml = textwrap.dedent("""\ - targets: - - repo: acme-org/service-a - branch: main - - repo: acme-org/service-b - branch: develop - - repo: acme-org/service-c - branch: main - """) - _write_fixtures(tmp_path, targets_yml=targets_yml) - - t_a = ConsumerTarget(repo="acme-org/service-a", branch="main") - t_b = ConsumerTarget(repo="acme-org/service-b", branch="develop") - t_c = ConsumerTarget(repo="acme-org/service-c", branch="main") - plan = _fake_plan(targets=(t_a, t_b, t_c)) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(t_a, PublishOutcome.UPDATED), - _fake_result(t_b, PublishOutcome.SKIPPED_DOWNGRADE), - _fake_result(t_c, PublishOutcome.FAILED, "Clone failed"), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(t_a) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - output = result.output - assert "updated" in output - # Rich may truncate column values; check for partial matches - assert "skipped" in output or "downgrade" in output - assert "failed" in output or "Clone" in output - - -# --------------------------------------------------------------------------- -# Verbose flag -# --------------------------------------------------------------------------- - - -class TestPublishVerbose: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_verbose_does_not_crash(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - result = runner.invoke(marketplace, ["publish", "--yes", "--verbose"]) - assert result.exit_code == 0, result.output - - -# --------------------------------------------------------------------------- -# State file path printed -# --------------------------------------------------------------------------- - - -class TestPublishStateFile: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_state_file_path_printed(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert "publish-state.json" in result.output - - -# --------------------------------------------------------------------------- -# Plan rendering -# --------------------------------------------------------------------------- - - -class TestPublishPlanRendering: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_plan_shows_marketplace_name( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert "test-marketplace" in result.output - assert "2.0.0" in result.output - - -# --------------------------------------------------------------------------- -# No-change outcomes (all targets already up to date) -# --------------------------------------------------------------------------- - - -class TestPublishNoChange: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_all_no_change_exit_0(self, MockPublisher, MockPr, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - targets = list(plan.targets) - - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(targets[0], PublishOutcome.NO_CHANGE), - _fake_result(targets[1], PublishOutcome.NO_CHANGE), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 0 - - -# --------------------------------------------------------------------------- -# Dry-run with --no-pr -# --------------------------------------------------------------------------- - - -class TestPublishDryRunNoPr: - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_dry_run_no_pr_exit_0(self, MockPublisher, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - result = runner.invoke( - marketplace, - ["publish", "--yes", "--dry-run", "--no-pr"], - ) - assert result.exit_code == 0, result.output - - -# --------------------------------------------------------------------------- -# Invalid target format (repo not owner/name) -# --------------------------------------------------------------------------- - - -class TestPublishInvalidRepoFormat: - def test_bad_repo_format(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - bad_targets = textwrap.dedent("""\ - targets: - - repo: just-a-name - branch: main - """) - _write_fixtures(tmp_path, targets_yml=bad_targets) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - assert "owner/name" in result.output - - -# --------------------------------------------------------------------------- -# Targets file with empty targets list -# --------------------------------------------------------------------------- - - -class TestPublishEmptyTargets: - def test_empty_targets_list(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - empty_targets = "targets: []\n" - _write_fixtures(tmp_path, targets_yml=empty_targets) - - result = runner.invoke(marketplace, ["publish", "--yes"]) - assert result.exit_code == 1 - - -# --------------------------------------------------------------------------- -# Default flags: allow-downgrade and allow-ref-change default to False -# --------------------------------------------------------------------------- - - -class TestPublishDefaultFlags: - @patch("apm_cli.commands.marketplace.publish.PrIntegrator") - @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") - def test_defaults_no_allow_downgrade_no_allow_ref_change( - self, MockPublisher, MockPr, runner, tmp_path, monkeypatch - ): - monkeypatch.chdir(tmp_path) - _write_fixtures(tmp_path) - - plan = _fake_plan() - mock_pub = MockPublisher.return_value - mock_pub.plan.return_value = plan - mock_pub.execute.return_value = [ - _fake_result(plan.targets[0]), - _fake_result(plan.targets[1]), - ] - - mock_pr = MockPr.return_value - mock_pr.check_available.return_value = (True, "gh 2.0") - mock_pr.open_or_update.return_value = _fake_pr_result(plan.targets[0]) - - runner.invoke(marketplace, ["publish", "--yes"]) - - _, kwargs = mock_pub.plan.call_args - assert kwargs.get("allow_downgrade") is False - assert kwargs.get("allow_ref_change") is False diff --git a/tests/unit/marketplace/test_git_stderr.py b/tests/unit/marketplace/test_git_stderr.py index e3e3c9a5e..850cef8e2 100644 --- a/tests/unit/marketplace/test_git_stderr.py +++ b/tests/unit/marketplace/test_git_stderr.py @@ -81,7 +81,7 @@ def test_auth_summary(self) -> None: def test_auth_hint(self) -> None: result = translate_git_stderr("fatal: Authentication failed", operation="push") assert "GITHUB_TOKEN" in result.hint - assert "apm marketplace doctor" in result.hint + assert "apm doctor" in result.hint def test_auth_case_insensitive(self) -> None: result = translate_git_stderr("FATAL: AUTHENTICATION FAILED") diff --git a/tests/unit/marketplace/test_marketplace_commands_phase3.py b/tests/unit/marketplace/test_marketplace_commands_phase3.py index 78530c81b..db72fdc09 100644 --- a/tests/unit/marketplace/test_marketplace_commands_phase3.py +++ b/tests/unit/marketplace/test_marketplace_commands_phase3.py @@ -28,10 +28,6 @@ MarketplacePlugin, MarketplaceSource, ) -from apm_cli.marketplace.pr_integration import PrState -from apm_cli.marketplace.publisher import ( - PublishOutcome, -) # --------------------------------------------------------------------------- # Shared fixtures @@ -807,273 +803,10 @@ def test_plugin_without_source_dict(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# _load_targets_file -# --------------------------------------------------------------------------- - - -class TestLoadTargetsFile: - def _write_yaml(self, tmp_path: Path, content: str) -> Path: - p = tmp_path / "targets.yaml" - p.write_text(content, encoding="utf-8") - return p - - def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = tmp_path / "targets.yaml" - p.write_text(": : :\ninvalid\t: [", encoding="utf-8") - targets, err = _load_targets_file(p) - assert targets is None - assert err is not None - assert "Invalid YAML" in err - - def test_oserror_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = tmp_path / "missing.yaml" - targets, err = _load_targets_file(p) - assert targets is None - assert "Cannot read" in err - - def test_no_targets_key_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "other: stuff\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "'targets' key" in err - - def test_empty_targets_list_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets: []\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "non-empty" in err - - def test_entry_not_dict_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - just-a-string\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "mapping" in err - - def test_missing_repo_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - branch: main\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "'repo' is required" in err - - def test_bad_repo_format_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - repo: single\n branch: main\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "owner/name" in err - - def test_missing_branch_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - repo: owner/name\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "'branch' is required" in err - - def test_path_traversal_in_path_in_repo_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = ( - "targets:\n - repo: owner/name\n branch: main\n path_in_repo: ../evil.yml\n" - ) - p = self._write_yaml(tmp_path, content) - targets, err = _load_targets_file(p) - assert targets is None - assert err is not None - - def test_valid_targets_file(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = "targets:\n - repo: owner/name\n branch: main\n" - p = self._write_yaml(tmp_path, content) - targets, err = _load_targets_file(p) - assert err is None - assert targets is not None - assert len(targets) == 1 - assert targets[0].repo == "owner/name" - - def test_empty_path_in_repo_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = "targets:\n - repo: owner/name\n branch: main\n path_in_repo: ' '\n" - p = self._write_yaml(tmp_path, content) - targets, err = _load_targets_file(p) - assert targets is None - assert "'path_in_repo'" in err - - -# --------------------------------------------------------------------------- -# _outcome_symbol -# --------------------------------------------------------------------------- - - -class TestOutcomeSymbol: - def test_updated(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_downgrade(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - def test_skipped_ref_change(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.SKIPPED_REF_CHANGE) == "[!]" - - def test_no_change(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.NO_CHANGE) == "[*]" - - -# --------------------------------------------------------------------------- -# _render_publish_footer -# --------------------------------------------------------------------------- - - -class TestRenderPublishFooter: - def test_no_failures_uses_success(self) -> None: - from apm_cli.commands.marketplace import _render_publish_footer - - logger = MagicMock() - _render_publish_footer(logger, updated=3, failed=0, total=3, dry_run=False) - logger.success.assert_called_once() - logger.warning.assert_not_called() - - def test_with_failures_uses_warning(self) -> None: - from apm_cli.commands.marketplace import _render_publish_footer - - logger = MagicMock() - _render_publish_footer(logger, updated=2, failed=1, total=3, dry_run=False) - logger.warning.assert_called_once() - logger.success.assert_not_called() - - def test_dry_run_suffix(self) -> None: - from apm_cli.commands.marketplace import _render_publish_footer - - logger = MagicMock() - _render_publish_footer(logger, updated=1, failed=0, total=1, dry_run=True) - call_msg = logger.success.call_args[0][0] - assert "dry-run" in call_msg - - -# --------------------------------------------------------------------------- -# _render_publish_plan # --------------------------------------------------------------------------- -class TestRenderPublishPlan: - def _make_plan(self) -> MagicMock: - plan = MagicMock() - plan.marketplace_name = "my-market" - plan.marketplace_version = "v2.0.0" - plan.new_ref = "refs/tags/v2.0.0" - plan.branch_name = "release/v2.0.0" - target = MagicMock() - target.repo = "acme/consumer" - target.branch = "main" - target.path_in_repo = "apm.yml" - plan.targets = [target] - return plan - - def test_no_console_uses_tree_item(self) -> None: - from apm_cli.commands.marketplace import _render_publish_plan - - logger = MagicMock() - plan = self._make_plan() - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_plan(logger, plan) - logger.tree_item.assert_called() - - def test_with_console_uses_rich(self) -> None: - from apm_cli.commands.marketplace import _render_publish_plan - - logger = MagicMock() - plan = self._make_plan() - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_plan(logger, plan) - mock_console.print.assert_called() - - -# --------------------------------------------------------------------------- -# _render_publish_summary -# --------------------------------------------------------------------------- - - -class TestRenderPublishSummary: - def _make_results_and_prs( - self, outcome: PublishOutcome = PublishOutcome.UPDATED - ) -> tuple[list[MagicMock], list[MagicMock]]: - target = MagicMock() - target.repo = "acme/consumer" - result = MagicMock() - result.target = target - result.outcome = outcome - result.message = "ok" - - pr_result = MagicMock() - pr_result.target = target - pr_result.state = PrState.OPENED - pr_result.pr_number = 42 - pr_result.pr_url = "https://github.com/acme/consumer/pull/42" - - return [result], [pr_result] - - def test_no_console_fallback(self) -> None: - from apm_cli.commands.marketplace import _render_publish_summary - - logger = MagicMock() - results, pr_results = self._make_results_and_prs() - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_summary(logger, results, pr_results, no_pr=False, dry_run=False) - logger.tree_item.assert_called() - - def test_with_console_rich(self) -> None: - from apm_cli.commands.marketplace import _render_publish_summary - - logger = MagicMock() - results, pr_results = self._make_results_and_prs() - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_summary(logger, results, pr_results, no_pr=False, dry_run=False) - mock_console.print.assert_called() - - def test_no_pr_flag_hides_pr_columns(self) -> None: - from apm_cli.commands.marketplace import _render_publish_summary - - logger = MagicMock() - results, pr_results = self._make_results_and_prs() - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_summary(logger, results, pr_results, no_pr=True, dry_run=False) - # Should still print without error - mock_console.print.assert_called() - - # --------------------------------------------------------------------------- -# _render_outdated_table # --------------------------------------------------------------------------- diff --git a/tests/unit/marketplace/test_marketplace_commands_surface.py b/tests/unit/marketplace/test_marketplace_commands_surface.py index 3a6f6c241..9d721e382 100644 --- a/tests/unit/marketplace/test_marketplace_commands_surface.py +++ b/tests/unit/marketplace/test_marketplace_commands_surface.py @@ -28,10 +28,6 @@ MarketplacePlugin, MarketplaceSource, ) -from apm_cli.marketplace.pr_integration import PrState -from apm_cli.marketplace.publisher import ( - PublishOutcome, -) # --------------------------------------------------------------------------- # Shared fixtures @@ -807,273 +803,10 @@ def test_plugin_without_source_dict(self, tmp_path: Path) -> None: # --------------------------------------------------------------------------- -# _load_targets_file -# --------------------------------------------------------------------------- - - -class TestLoadTargetsFile: - def _write_yaml(self, tmp_path: Path, content: str) -> Path: - p = tmp_path / "targets.yaml" - p.write_text(content, encoding="utf-8") - return p - - def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = tmp_path / "targets.yaml" - p.write_text(": : :\ninvalid\t: [", encoding="utf-8") - targets, err = _load_targets_file(p) - assert targets is None - assert err is not None - assert "Invalid YAML" in err - - def test_oserror_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = tmp_path / "missing.yaml" - targets, err = _load_targets_file(p) - assert targets is None - assert "Cannot read" in err - - def test_no_targets_key_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "other: stuff\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "'targets' key" in err - - def test_empty_targets_list_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets: []\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "non-empty" in err - - def test_entry_not_dict_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - just-a-string\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "mapping" in err - - def test_missing_repo_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - branch: main\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "'repo' is required" in err - - def test_bad_repo_format_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - repo: single\n branch: main\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "owner/name" in err - - def test_missing_branch_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - p = self._write_yaml(tmp_path, "targets:\n - repo: owner/name\n") - targets, err = _load_targets_file(p) - assert targets is None - assert "'branch' is required" in err - - def test_path_traversal_in_path_in_repo_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = ( - "targets:\n - repo: owner/name\n branch: main\n path_in_repo: ../evil.yml\n" - ) - p = self._write_yaml(tmp_path, content) - targets, err = _load_targets_file(p) - assert targets is None - assert err is not None - - def test_valid_targets_file(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = "targets:\n - repo: owner/name\n branch: main\n" - p = self._write_yaml(tmp_path, content) - targets, err = _load_targets_file(p) - assert err is None - assert targets is not None - assert len(targets) == 1 - assert targets[0].repo == "owner/name" - - def test_empty_path_in_repo_returns_error(self, tmp_path: Path) -> None: - from apm_cli.commands.marketplace import _load_targets_file - - content = "targets:\n - repo: owner/name\n branch: main\n path_in_repo: ' '\n" - p = self._write_yaml(tmp_path, content) - targets, err = _load_targets_file(p) - assert targets is None - assert "'path_in_repo'" in err - - -# --------------------------------------------------------------------------- -# _outcome_symbol -# --------------------------------------------------------------------------- - - -class TestOutcomeSymbol: - def test_updated(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.UPDATED) == "[+]" - - def test_failed(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.FAILED) == "[x]" - - def test_skipped_downgrade(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.SKIPPED_DOWNGRADE) == "[!]" - - def test_skipped_ref_change(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.SKIPPED_REF_CHANGE) == "[!]" - - def test_no_change(self) -> None: - from apm_cli.commands.marketplace import _outcome_symbol - - assert _outcome_symbol(PublishOutcome.NO_CHANGE) == "[*]" - - -# --------------------------------------------------------------------------- -# _render_publish_footer -# --------------------------------------------------------------------------- - - -class TestRenderPublishFooter: - def test_no_failures_uses_success(self) -> None: - from apm_cli.commands.marketplace import _render_publish_footer - - logger = MagicMock() - _render_publish_footer(logger, updated=3, failed=0, total=3, dry_run=False) - logger.success.assert_called_once() - logger.warning.assert_not_called() - - def test_with_failures_uses_warning(self) -> None: - from apm_cli.commands.marketplace import _render_publish_footer - - logger = MagicMock() - _render_publish_footer(logger, updated=2, failed=1, total=3, dry_run=False) - logger.warning.assert_called_once() - logger.success.assert_not_called() - - def test_dry_run_suffix(self) -> None: - from apm_cli.commands.marketplace import _render_publish_footer - - logger = MagicMock() - _render_publish_footer(logger, updated=1, failed=0, total=1, dry_run=True) - call_msg = logger.success.call_args[0][0] - assert "dry-run" in call_msg - - -# --------------------------------------------------------------------------- -# _render_publish_plan # --------------------------------------------------------------------------- -class TestRenderPublishPlan: - def _make_plan(self) -> MagicMock: - plan = MagicMock() - plan.marketplace_name = "my-market" - plan.marketplace_version = "v2.0.0" - plan.new_ref = "refs/tags/v2.0.0" - plan.branch_name = "release/v2.0.0" - target = MagicMock() - target.repo = "acme/consumer" - target.branch = "main" - target.path_in_repo = "apm.yml" - plan.targets = [target] - return plan - - def test_no_console_uses_tree_item(self) -> None: - from apm_cli.commands.marketplace import _render_publish_plan - - logger = MagicMock() - plan = self._make_plan() - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_plan(logger, plan) - logger.tree_item.assert_called() - - def test_with_console_uses_rich(self) -> None: - from apm_cli.commands.marketplace import _render_publish_plan - - logger = MagicMock() - plan = self._make_plan() - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_plan(logger, plan) - mock_console.print.assert_called() - - -# --------------------------------------------------------------------------- -# _render_publish_summary -# --------------------------------------------------------------------------- - - -class TestRenderPublishSummary: - def _make_results_and_prs( - self, outcome: PublishOutcome = PublishOutcome.UPDATED - ) -> tuple[list[MagicMock], list[MagicMock]]: - target = MagicMock() - target.repo = "acme/consumer" - result = MagicMock() - result.target = target - result.outcome = outcome - result.message = "ok" - - pr_result = MagicMock() - pr_result.target = target - pr_result.state = PrState.OPENED - pr_result.pr_number = 42 - pr_result.pr_url = "https://github.com/acme/consumer/pull/42" - - return [result], [pr_result] - - def test_no_console_fallback(self) -> None: - from apm_cli.commands.marketplace import _render_publish_summary - - logger = MagicMock() - results, pr_results = self._make_results_and_prs() - with patch("apm_cli.commands.marketplace._get_console", return_value=None): - _render_publish_summary(logger, results, pr_results, no_pr=False, dry_run=False) - logger.tree_item.assert_called() - - def test_with_console_rich(self) -> None: - from apm_cli.commands.marketplace import _render_publish_summary - - logger = MagicMock() - results, pr_results = self._make_results_and_prs() - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_summary(logger, results, pr_results, no_pr=False, dry_run=False) - mock_console.print.assert_called() - - def test_no_pr_flag_hides_pr_columns(self) -> None: - from apm_cli.commands.marketplace import _render_publish_summary - - logger = MagicMock() - results, pr_results = self._make_results_and_prs() - mock_console = MagicMock() - with patch("apm_cli.commands.marketplace._get_console", return_value=mock_console): - _render_publish_summary(logger, results, pr_results, no_pr=True, dry_run=False) - # Should still print without error - mock_console.print.assert_called() - - # --------------------------------------------------------------------------- -# _render_outdated_table # --------------------------------------------------------------------------- diff --git a/tests/unit/marketplace/test_pr_integration.py b/tests/unit/marketplace/test_pr_integration.py deleted file mode 100644 index 5387df34b..000000000 --- a/tests/unit/marketplace/test_pr_integration.py +++ /dev/null @@ -1,1089 +0,0 @@ -"""Tests for pr_integration.py -- PrIntegrator, PrState, PrResult.""" - -from __future__ import annotations - -import json -import subprocess -from typing import Any - -import pytest - -from apm_cli.marketplace.pr_integration import ( - PrIntegrator, - PrResult, - PrState, - _build_body, - _build_title, - _extract_short_hash, - _redact_token, -) -from apm_cli.marketplace.publisher import ( - ConsumerTarget, - PublishOutcome, - PublishPlan, - TargetResult, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_plan( - *, - marketplace_name: str = "acme-tools", - marketplace_version: str = "2.0.0", - branch_name: str = "apm/marketplace-update-acme-tools-2.0.0-a1b2c3d4", - new_ref: str = "v2.0.0", - short_hash: str = "a1b2c3d4", - targets: tuple[ConsumerTarget, ...] | None = None, -) -> PublishPlan: - """Return a minimal ``PublishPlan`` for tests.""" - if targets is None: - targets = (_make_target(),) - return PublishPlan( - marketplace_name=marketplace_name, - marketplace_version=marketplace_version, - targets=targets, - commit_message=( - f"chore(apm): bump {marketplace_name} to {marketplace_version}\n" - f"\nUpdated by apm marketplace publish.\n" - f"\nAPM-Publish-Id: {short_hash}" - ), - branch_name=branch_name, - new_ref=new_ref, - tag_pattern_used="v{version}", - short_hash=short_hash, - ) - - -def _make_target( - *, - repo: str = "acme-org/consumer", - branch: str = "main", - path_in_repo: str = "apm.yml", -) -> ConsumerTarget: - """Return a minimal ``ConsumerTarget`` for tests.""" - return ConsumerTarget(repo=repo, branch=branch, path_in_repo=path_in_repo) - - -def _make_target_result( - *, - target: ConsumerTarget | None = None, - outcome: PublishOutcome = PublishOutcome.UPDATED, - message: str = "Updated 2 refs.", -) -> TargetResult: - """Return a ``TargetResult`` for tests.""" - if target is None: - target = _make_target() - return TargetResult(target=target, outcome=outcome, message=message) - - -class GhRunner: - """Injectable ``subprocess.run`` replacement for ``gh`` CLI tests. - - Records all calls and returns pre-configured responses keyed by - the first few arguments of each command. - """ - - def __init__(self) -> None: - self.calls: list[tuple[list[str], dict[str, Any]]] = [] - self._responses: dict[tuple[str, ...], subprocess.CompletedProcess] = {} - self._errors: dict[tuple[str, ...], Exception] = {} - - def set_response( - self, - key: tuple[str, ...], - *, - stdout: str = "", - stderr: str = "", - returncode: int = 0, - ) -> None: - """Pre-configure a response for commands matching *key*.""" - self._responses[key] = subprocess.CompletedProcess( - list(key), - returncode, - stdout=stdout, - stderr=stderr, - ) - - def set_error( - self, - key: tuple[str, ...], - exc: Exception, - ) -> None: - """Pre-configure an exception for commands matching *key*.""" - self._errors[key] = exc - - def _match_key(self, cmd: list[str]) -> tuple[str, ...] | None: - """Find the longest registered key that is a prefix of *cmd*.""" - best: tuple[str, ...] | None = None - for key in list(self._responses) + list(self._errors): - if tuple(cmd[: len(key)]) == key: - if best is None or len(key) > len(best): - best = key - return best - - def __call__(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - self.calls.append((list(cmd), dict(kwargs))) - - key = self._match_key(cmd) - - # Check for configured errors first - if key is not None and key in self._errors: - raise self._errors[key] - - # Check for configured responses - if key is not None and key in self._responses: - resp = self._responses[key] - if kwargs.get("check") and resp.returncode != 0: - raise subprocess.CalledProcessError( - resp.returncode, - cmd, - output=resp.stdout, - stderr=resp.stderr, - ) - return resp - - # Default: success with empty output - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - -def _expected_body( - plan: PublishPlan | None = None, - target: ConsumerTarget | None = None, -) -> str: - """Return the expected PR body for the default plan/target.""" - if plan is None: - plan = _make_plan() - if target is None: - target = _make_target() - return _build_body(plan, target) - - -# --------------------------------------------------------------------------- -# PrState enum -# --------------------------------------------------------------------------- - - -class TestPrState: - """Tests for the PrState enum values.""" - - def test_opened_value(self) -> None: - assert PrState.OPENED.value == "opened" - - def test_updated_value(self) -> None: - assert PrState.UPDATED.value == "updated" - - def test_skipped_value(self) -> None: - assert PrState.SKIPPED.value == "skipped" - - def test_failed_value(self) -> None: - assert PrState.FAILED.value == "failed" - - def test_disabled_value(self) -> None: - assert PrState.DISABLED.value == "disabled" - - def test_is_str_subclass(self) -> None: - assert isinstance(PrState.OPENED, str) - - -# --------------------------------------------------------------------------- -# PrResult dataclass -# --------------------------------------------------------------------------- - - -class TestPrResult: - """Tests for the PrResult frozen dataclass.""" - - def test_frozen(self) -> None: - result = PrResult( - target=_make_target(), - state=PrState.OPENED, - pr_number=42, - pr_url="https://github.com/acme-org/consumer/pull/42", - message="PR opened.", - ) - with pytest.raises(AttributeError): - result.state = PrState.FAILED # type: ignore[misc] - - def test_fields_accessible(self) -> None: - target = _make_target() - result = PrResult( - target=target, - state=PrState.OPENED, - pr_number=42, - pr_url="https://github.com/acme-org/consumer/pull/42", - message="PR opened.", - ) - assert result.target is target - assert result.pr_number == 42 - assert result.pr_url == "https://github.com/acme-org/consumer/pull/42" - assert result.message == "PR opened." - - -# --------------------------------------------------------------------------- -# check_available -# --------------------------------------------------------------------------- - - -class TestCheckAvailable: - """Tests for PrIntegrator.check_available().""" - - def test_gh_version_fails_not_found(self) -> None: - """gh --version returns non-zero -> False with install hint.""" - runner = GhRunner() - runner.set_response( - ("gh", "--version"), - returncode=1, - stderr="not found", - ) - integrator = PrIntegrator(runner=runner) - ok, msg = integrator.check_available() - - assert ok is False - assert "gh CLI not found on PATH" in msg - assert "https://cli.github.com/" in msg - - def test_gh_version_os_error(self) -> None: - """gh --version raises OSError -> False with install hint.""" - runner = GhRunner() - runner.set_error( - ("gh", "--version"), - FileNotFoundError("no such file"), - ) - integrator = PrIntegrator(runner=runner) - ok, msg = integrator.check_available() - - assert ok is False - assert "gh CLI not found on PATH" in msg - - def test_gh_auth_fails(self) -> None: - """gh auth status returns non-zero -> False with auth hint.""" - runner = GhRunner() - runner.set_response( - ("gh", "--version"), - stdout="gh version 2.50.0\n", - ) - runner.set_response( - ("gh", "auth", "status"), - returncode=1, - stderr="not logged in", - ) - integrator = PrIntegrator(runner=runner) - ok, msg = integrator.check_available() - - assert ok is False - assert "gh CLI is not authenticated" in msg - assert "gh auth login" in msg - - def test_gh_auth_os_error(self) -> None: - """gh auth status raises OSError -> False with auth hint.""" - runner = GhRunner() - runner.set_response( - ("gh", "--version"), - stdout="gh version 2.50.0\n", - ) - runner.set_error( - ("gh", "auth", "status"), - OSError("pipe broken"), - ) - integrator = PrIntegrator(runner=runner) - ok, msg = integrator.check_available() - - assert ok is False - assert "gh CLI is not authenticated" in msg - - def test_both_succeed(self) -> None: - """gh --version and gh auth status both succeed -> True.""" - runner = GhRunner() - runner.set_response( - ("gh", "--version"), - stdout="gh version 2.50.0 (2025-01-01)\n", - ) - runner.set_response(("gh", "auth", "status"), stdout="Logged in\n") - integrator = PrIntegrator(runner=runner) - ok, version = integrator.check_available() - - assert ok is True - assert "2.50.0" in version - - -# --------------------------------------------------------------------------- -# open_or_update -- early returns -# --------------------------------------------------------------------------- - - -class TestOpenOrUpdateEarlyReturns: - """Tests for conditions that return early without calling gh.""" - - def test_no_pr_flag_returns_disabled(self) -> None: - """no_pr=True -> DISABLED, no runner calls.""" - runner = GhRunner() - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - no_pr=True, - ) - assert result.state == PrState.DISABLED - assert result.pr_number is None - assert result.pr_url is None - assert "--no-pr" in result.message - assert len(runner.calls) == 0 - - def test_no_change_outcome_returns_skipped(self) -> None: - """outcome=NO_CHANGE -> SKIPPED, no runner calls.""" - runner = GhRunner() - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(outcome=PublishOutcome.NO_CHANGE), - ) - assert result.state == PrState.SKIPPED - assert "no-change" in result.message - assert len(runner.calls) == 0 - - def test_skipped_downgrade_outcome_returns_skipped(self) -> None: - """outcome=SKIPPED_DOWNGRADE -> SKIPPED, no runner calls.""" - runner = GhRunner() - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(outcome=PublishOutcome.SKIPPED_DOWNGRADE), - ) - assert result.state == PrState.SKIPPED - assert "skipped-downgrade" in result.message - assert len(runner.calls) == 0 - - def test_skipped_ref_change_outcome_returns_skipped(self) -> None: - """outcome=SKIPPED_REF_CHANGE -> SKIPPED, no runner calls.""" - runner = GhRunner() - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(outcome=PublishOutcome.SKIPPED_REF_CHANGE), - ) - assert result.state == PrState.SKIPPED - assert "skipped-ref-change" in result.message - assert len(runner.calls) == 0 - - def test_failed_outcome_returns_skipped(self) -> None: - """outcome=FAILED -> SKIPPED, no runner calls.""" - runner = GhRunner() - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(outcome=PublishOutcome.FAILED), - ) - assert result.state == PrState.SKIPPED - assert "failed" in result.message - assert len(runner.calls) == 0 - - -# --------------------------------------------------------------------------- -# open_or_update -- happy path: create PR -# --------------------------------------------------------------------------- - - -class TestCreatePr: - """Tests for creating a new PR (no existing PR).""" - - def test_create_pr_happy_path(self) -> None: - """UPDATED outcome, no existing PR -> OPENED with number/url.""" - runner = GhRunner() - # gh pr list returns empty array (no existing PR) - runner.set_response( - ("gh", "pr", "list"), - stdout="[]\n", - ) - # gh pr create returns PR URL - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/42\n", - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.OPENED - assert result.pr_number == 42 - assert result.pr_url == "https://github.com/acme-org/consumer/pull/42" - assert result.message == "PR opened." - - def test_create_pr_passes_correct_repo_and_base(self) -> None: - """gh pr create receives --repo and --base from target.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/1\n", - ) - target = _make_target(repo="acme-org/other-repo", branch="develop") - integrator = PrIntegrator(runner=runner) - integrator.open_or_update( - _make_plan(), - target, - _make_target_result(target=target), - ) - - # Find the pr create call - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - assert len(create_calls) == 1 - cmd = create_calls[0] - assert "--repo" in cmd - repo_idx = cmd.index("--repo") - assert cmd[repo_idx + 1] == "acme-org/other-repo" - assert "--base" in cmd - base_idx = cmd.index("--base") - assert cmd[base_idx + 1] == "develop" - - def test_create_pr_passes_head_branch(self) -> None: - """gh pr create receives --head from plan.branch_name.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/1\n", - ) - plan = _make_plan( - branch_name="apm/marketplace-update-custom-1.0.0-deadbeef", - ) - integrator = PrIntegrator(runner=runner) - integrator.open_or_update( - plan, - _make_target(), - _make_target_result(), - ) - - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - cmd = create_calls[0] - head_idx = cmd.index("--head") - assert cmd[head_idx + 1] == "apm/marketplace-update-custom-1.0.0-deadbeef" - - def test_create_pr_with_draft_flag(self) -> None: - """draft=True -> gh pr create command includes --draft.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/7\n", - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - draft=True, - ) - - assert result.state == PrState.OPENED - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - assert any("--draft" in c for c in create_calls) - - def test_create_pr_without_draft_flag(self) -> None: - """draft=False (default) -> gh pr create has no --draft.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/7\n", - ) - integrator = PrIntegrator(runner=runner) - integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - assert not any("--draft" in c for c in create_calls) - - def test_dry_run_no_existing_pr(self) -> None: - """dry_run=True, no existing PR -> OPENED with None number/url.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - dry_run=True, - ) - - assert result.state == PrState.OPENED - assert result.pr_number is None - assert result.pr_url is None - assert "[dry-run]" in result.message - - # No pr create call should have been made - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - assert len(create_calls) == 0 - - def test_pr_url_parsing_pull_number(self) -> None: - """PR number is correctly parsed from the URL.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/99\n", - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.pr_number == 99 - assert result.pr_url == "https://github.com/acme-org/consumer/pull/99" - - def test_pr_url_multiline_stdout(self) -> None: - """PR URL is parsed from the last line of stdout.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout=( - "Creating pull request for feature-branch into main\n" - "https://github.com/acme-org/consumer/pull/123\n" - ), - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.pr_number == 123 - assert "pull/123" in (result.pr_url or "") - - -# --------------------------------------------------------------------------- -# open_or_update -- existing PR -# --------------------------------------------------------------------------- - - -class TestExistingPr: - """Tests for when a PR already exists.""" - - def test_existing_pr_body_unchanged(self) -> None: - """Existing PR with identical body -> UPDATED, 'unchanged'.""" - plan = _make_plan() - target = _make_target() - body = _build_body(plan, target) - - runner = GhRunner() - runner.set_response( - ("gh", "pr", "list"), - stdout=json.dumps( - [ - { - "number": 10, - "url": "https://github.com/acme-org/consumer/pull/10", - "body": body, - "headRefOid": "abc123", - } - ] - ), - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - plan, - target, - _make_target_result(target=target), - ) - - assert result.state == PrState.UPDATED - assert result.pr_number == 10 - assert result.pr_url == "https://github.com/acme-org/consumer/pull/10" - assert "unchanged" in result.message - - # No pr edit call should have been made - edit_calls = [c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "edit"] - assert len(edit_calls) == 0 - - def test_existing_pr_body_different(self) -> None: - """Existing PR with different body -> UPDATED, 'body updated'.""" - plan = _make_plan() - target = _make_target() - - runner = GhRunner() - runner.set_response( - ("gh", "pr", "list"), - stdout=json.dumps( - [ - { - "number": 10, - "url": "https://github.com/acme-org/consumer/pull/10", - "body": "Old body text", - "headRefOid": "abc123", - } - ] - ), - ) - runner.set_response(("gh", "pr", "edit"), stdout="") - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - plan, - target, - _make_target_result(target=target), - ) - - assert result.state == PrState.UPDATED - assert result.pr_number == 10 - assert "body updated" in result.message.lower() - - # pr edit should have been called - edit_calls = [c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "edit"] - assert len(edit_calls) == 1 - cmd = edit_calls[0] - assert "--body-file" in cmd - - def test_existing_pr_dry_run_still_updates_body(self) -> None: - """dry_run only affects creation; existing PR body update proceeds.""" - plan = _make_plan() - target = _make_target() - - runner = GhRunner() - runner.set_response( - ("gh", "pr", "list"), - stdout=json.dumps( - [ - { - "number": 10, - "url": "https://github.com/acme-org/consumer/pull/10", - "body": "Stale body", - "headRefOid": "abc123", - } - ] - ), - ) - runner.set_response(("gh", "pr", "edit"), stdout="") - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - plan, - target, - _make_target_result(target=target), - dry_run=True, - ) - - # Even with dry_run, existing PR body is updated - assert result.state == PrState.UPDATED - assert result.pr_number == 10 - - -# --------------------------------------------------------------------------- -# open_or_update -- error handling -# --------------------------------------------------------------------------- - - -class TestErrorHandling: - """Tests for error conditions in open_or_update.""" - - def test_gh_pr_create_auth_error(self) -> None: - """gh pr create fails with auth error -> FAILED, redacted stderr.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_error( - ("gh", "pr", "create"), - subprocess.CalledProcessError( - 1, - ["gh", "pr", "create"], - output="", - stderr=( - "fatal: authentication failed for 'https://x-access-token:ghp_FAKE@github.com'" - ), - ), - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.FAILED - assert result.pr_number is None - assert result.pr_url is None - assert "ghp_FAKE" not in result.message - assert "***" in result.message - - def test_gh_pr_list_malformed_json(self) -> None: - """gh pr list returns non-JSON -> FAILED.""" - runner = GhRunner() - runner.set_response( - ("gh", "pr", "list"), - stdout="not valid json{{{", - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.FAILED - assert "parse" in result.message.lower() or "OS error" in result.message - - def test_timeout_expired(self) -> None: - """gh times out -> FAILED with timeout message.""" - runner = GhRunner() - runner.set_error( - ("gh", "pr", "list"), - subprocess.TimeoutExpired(cmd=["gh", "pr", "list"], timeout=30), - ) - integrator = PrIntegrator(runner=runner, timeout_s=30.0) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.FAILED - assert "timed out" in result.message - - def test_os_error(self) -> None: - """OSError from runner -> FAILED.""" - runner = GhRunner() - runner.set_error( - ("gh", "pr", "list"), - OSError("permission denied"), - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.FAILED - assert "OS error" in result.message - - def test_gh_pr_list_fails_called_process_error(self) -> None: - """gh pr list returns non-zero -> FAILED.""" - runner = GhRunner() - runner.set_error( - ("gh", "pr", "list"), - subprocess.CalledProcessError( - 1, - ["gh", "pr", "list"], - output="", - stderr="HTTP 403", - ), - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.FAILED - - -# --------------------------------------------------------------------------- -# Token redaction -# --------------------------------------------------------------------------- - - -class TestTokenRedaction: - """Tests for token redaction in error messages.""" - - def test_redacts_access_token(self) -> None: - """Tokens in stderr are replaced with ***.""" - text = ( - "fatal: authentication failed for " - "'https://x-access-token:ghp_FAKE123@github.com/acme-org/repo'" - ) - redacted = _redact_token(text) - assert "ghp_FAKE123" not in redacted - assert "https://***@" in redacted - - def test_redacts_multiple_tokens(self) -> None: - """Multiple token patterns are all redacted.""" - text = "tried https://user:token1@host1 and https://user:token2@host2" - redacted = _redact_token(text) - assert "token1" not in redacted - assert "token2" not in redacted - - def test_no_token_unchanged(self) -> None: - """Text without tokens is returned unchanged.""" - text = "fatal: repository not found" - assert _redact_token(text) == text - - def test_failed_message_redacts_token_from_stderr(self) -> None: - """Full integration: FAILED result message has tokens redacted.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_error( - ("gh", "pr", "create"), - subprocess.CalledProcessError( - 128, - ["gh", "pr", "create"], - output="", - stderr="https://x-access-token:ghp_SECRET@github.com 403", - ), - ) - integrator = PrIntegrator(runner=runner) - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - assert result.state == PrState.FAILED - assert "ghp_SECRET" not in result.message - assert "***" in result.message - - -# --------------------------------------------------------------------------- -# PR title and body templates -# --------------------------------------------------------------------------- - - -class TestTemplates: - """Tests for PR title and body rendering.""" - - def test_title_format(self) -> None: - """Title follows the chore(apm) convention.""" - plan = _make_plan( - marketplace_name="acme-tools", - marketplace_version="3.1.0", - ) - title = _build_title(plan) - assert title == "chore(apm): bump acme-tools to 3.1.0" - - def test_body_contains_marketplace_name(self) -> None: - plan = _make_plan(marketplace_name="my-mkt") - body = _build_body(plan, _make_target()) - assert "`my-mkt`" in body - - def test_body_contains_version(self) -> None: - plan = _make_plan(marketplace_version="4.0.0") - body = _build_body(plan, _make_target()) - assert "`4.0.0`" in body - - def test_body_contains_new_ref(self) -> None: - plan = _make_plan(new_ref="v5.0.0") - body = _build_body(plan, _make_target()) - assert "`v5.0.0`" in body - - def test_body_contains_branch_name(self) -> None: - plan = _make_plan( - branch_name="apm/marketplace-update-x-1.0-abc12345", - ) - body = _build_body(plan, _make_target()) - assert "`apm/marketplace-update-x-1.0-abc12345`" in body - - def test_body_contains_path_in_repo(self) -> None: - target = _make_target(path_in_repo="config/apm.yml") - body = _build_body(_make_plan(), target) - assert "`config/apm.yml`" in body - - def test_body_contains_apm_publish_id_comment(self) -> None: - plan = _make_plan(short_hash="deadbeef") - body = _build_body(plan, _make_target()) - assert "" in body - - def test_body_short_hash_fallback_from_branch_name(self) -> None: - """When plan.short_hash is empty, derive from branch_name.""" - plan = _make_plan( - short_hash="", - branch_name="apm/marketplace-update-tools-1.0.0-ff00ff00", - ) - body = _build_body(plan, _make_target()) - assert "" in body - - def test_body_all_ascii(self) -> None: - """Body must contain only ASCII characters.""" - plan = _make_plan() - body = _build_body(plan, _make_target()) - assert body.isascii() - - def test_title_all_ascii(self) -> None: - """Title must contain only ASCII characters.""" - plan = _make_plan() - title = _build_title(plan) - assert title.isascii() - - def test_body_starts_with_automated_update(self) -> None: - body = _build_body(_make_plan(), _make_target()) - assert body.startswith("Automated update from `apm marketplace publish`.") - - -# --------------------------------------------------------------------------- -# _extract_short_hash -# --------------------------------------------------------------------------- - - -class TestExtractShortHash: - """Tests for _extract_short_hash helper.""" - - def test_uses_plan_short_hash_when_set(self) -> None: - plan = _make_plan(short_hash="aabbccdd") - assert _extract_short_hash(plan) == "aabbccdd" - - def test_falls_back_to_branch_name(self) -> None: - plan = _make_plan( - short_hash="", - branch_name="apm/marketplace-update-tools-1.0.0-12345678", - ) - assert _extract_short_hash(plan) == "12345678" - - def test_empty_when_no_hash_available(self) -> None: - plan = _make_plan(short_hash="", branch_name="simple-branch") - # rsplit("-", 1) on "simple-branch" -> ["simple", "branch"] - # The fallback returns the last segment - result = _extract_short_hash(plan) - assert isinstance(result, str) - - -# --------------------------------------------------------------------------- -# PrIntegrator construction -# --------------------------------------------------------------------------- - - -class TestPrIntegratorInit: - """Tests for PrIntegrator constructor defaults.""" - - def test_default_gh_bin(self) -> None: - integrator = PrIntegrator() - assert integrator._gh_bin == "gh" - - def test_custom_gh_bin(self) -> None: - integrator = PrIntegrator(gh_bin="/usr/local/bin/gh") - assert integrator._gh_bin == "/usr/local/bin/gh" - - def test_custom_timeout(self) -> None: - integrator = PrIntegrator(timeout_s=60.0) - assert integrator._timeout_s == 60.0 - - def test_runner_injectable(self) -> None: - runner = GhRunner() - integrator = PrIntegrator(runner=runner) - assert integrator._runner is runner - - -# --------------------------------------------------------------------------- -# Integration-level: end-to-end flow -# --------------------------------------------------------------------------- - - -class TestEndToEnd: - """Integration-level tests combining multiple steps.""" - - def test_full_create_flow(self) -> None: - """Full flow: check_available -> open_or_update (create).""" - runner = GhRunner() - runner.set_response( - ("gh", "--version"), - stdout="gh version 2.50.0\n", - ) - runner.set_response(("gh", "auth", "status"), stdout="Logged in\n") - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/55\n", - ) - - integrator = PrIntegrator(runner=runner) - ok, _ = integrator.check_available() - assert ok is True - - result = integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - assert result.state == PrState.OPENED - assert result.pr_number == 55 - - def test_pr_list_uses_check_true(self) -> None: - """gh pr list is called with check=True.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/1\n", - ) - integrator = PrIntegrator(runner=runner) - integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - list_calls = [ - (c, kw) for c, kw in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "list" - ] - assert len(list_calls) == 1 - _, kw = list_calls[0] - assert kw.get("check") is True - - def test_body_file_used_in_create(self) -> None: - """gh pr create uses --body-file, not --body.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/1\n", - ) - integrator = PrIntegrator(runner=runner) - integrator.open_or_update( - _make_plan(), - _make_target(), - _make_target_result(), - ) - - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - cmd = create_calls[0] - assert "--body-file" in cmd - assert "--body" not in cmd or "--body-file" in cmd - - def test_title_passed_to_create(self) -> None: - """gh pr create receives the correct --title.""" - runner = GhRunner() - runner.set_response(("gh", "pr", "list"), stdout="[]\n") - runner.set_response( - ("gh", "pr", "create"), - stdout="https://github.com/acme-org/consumer/pull/1\n", - ) - plan = _make_plan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - ) - integrator = PrIntegrator(runner=runner) - integrator.open_or_update( - plan, - _make_target(), - _make_target_result(), - ) - - create_calls = [ - c for c, _ in runner.calls if len(c) >= 3 and c[1] == "pr" and c[2] == "create" - ] - cmd = create_calls[0] - title_idx = cmd.index("--title") - assert cmd[title_idx + 1] == "chore(apm): bump acme-tools to 2.0.0" diff --git a/tests/unit/marketplace/test_publisher.py b/tests/unit/marketplace/test_publisher.py deleted file mode 100644 index 8970f9445..000000000 --- a/tests/unit/marketplace/test_publisher.py +++ /dev/null @@ -1,1433 +0,0 @@ -"""Tests for publisher.py -- MarketplacePublisher, PublishState, data model.""" - -from __future__ import annotations - -import json # noqa: F401 -import os -import subprocess -import textwrap -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -import pytest - -from apm_cli.marketplace.publisher import ( - ConsumerTarget, - MarketplacePublisher, - PublishOutcome, - PublishPlan, - PublishState, - TargetResult, - _redact_token, -) -from apm_cli.utils.path_security import PathTraversalError - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_BASIC_MARKETPLACE_YML = textwrap.dedent("""\ - name: acme-tools - description: Curated developer tools - version: 2.0.0 - owner: - name: Acme Corp - packages: - - name: code-reviewer - source: acme-org/code-reviewer - version: "^2.0.0" - description: Automated code review assistant - tags: [review, quality] -""") - -_CONSUMER_APM_YML_V1 = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v1.0.0 -""") - -_CONSUMER_APM_YML_V2 = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v2.0.0 -""") - -_CONSUMER_APM_YML_NO_REF = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools -""") - -_CONSUMER_APM_YML_BRANCH_REF = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#main -""") - -_CONSUMER_APM_YML_SHA_REF = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#abc123def456 -""") - -_CONSUMER_APM_YML_NO_MATCH = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@other-marketplace#v1.0.0 -""") - -_CONSUMER_APM_YML_MULTI_MATCH = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v1.0.0 - - test-generator@acme-tools#v1.0.0 -""") - -_CONSUMER_APM_YML_MIXED = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v1.0.0 - - microsoft/apm-sample-package#v1.0.0 -""") - -_CONSUMER_APM_YML_CASE_INSENSITIVE = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@ACME-TOOLS#v1.0.0 -""") - - -def _write_marketplace_yml(root: Path, content: str = _BASIC_MARKETPLACE_YML) -> Path: - """Write a marketplace.yml file and return the root path.""" - yml_path = root / "marketplace.yml" - yml_path.write_text(content, encoding="utf-8") - return root - - -def _fixed_clock( - ts: datetime | None = None, -) -> datetime: - """Return a fixed timestamp for deterministic tests.""" - if ts is not None: - return ts - return datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) - - -class FakeRunner: - """Injectable ``subprocess.run`` replacement for tests. - - Records all calls and can be configured with: - - ``clone_files``: dict mapping repo -> {path: content} to create - when a ``git clone`` command targets that repo. - - ``log_output``: stdout returned by ``git log`` commands. - - ``fail_on``: set of (verb,) tuples; when a command starts with - ``["git", verb]``, the runner raises ``CalledProcessError``. - """ - - def __init__(self) -> None: - self.calls: list[tuple[list[str], dict[str, Any]]] = [] - self.clone_files: dict[str, dict[str, str]] = {} - self.log_output: str = "" - self.fail_on: set[str] = set() - - def __call__(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess: - self.calls.append((list(cmd), dict(kwargs))) - - # Check for configured failures - if len(cmd) >= 2 and cmd[1] in self.fail_on: - if kwargs.get("check"): - raise subprocess.CalledProcessError(1, cmd, output="", stderr="command failed") - return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="command failed") - - # Handle git clone - if len(cmd) >= 2 and cmd[0] == "git" and cmd[1] == "clone": - target_dir = cmd[-1] - os.makedirs(target_dir, exist_ok=True) - for repo, files in self.clone_files.items(): - clone_url = f"https://github.com/{repo}.git" - if clone_url in cmd: - for path, content in files.items(): - full_path = Path(target_dir) / path - full_path.parent.mkdir(parents=True, exist_ok=True) - full_path.write_text(content, encoding="utf-8") - break - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - # Handle git log (for safe_force_push) - if len(cmd) >= 2 and cmd[0] == "git" and cmd[1] == "log": - return subprocess.CompletedProcess(cmd, 0, stdout=self.log_output, stderr="") - - # Default: success - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - def git_calls(self, verb: str | None = None) -> list[list[str]]: - """Return git command lists, optionally filtered by verb.""" - result = [] - for cmd, _ in self.calls: - if cmd[0] != "git": - continue - if verb is None or (len(cmd) > 1 and cmd[1] == verb): - result.append(cmd) - return result - - -def _make_publisher( - tmp_path: Path, - *, - yml_content: str = _BASIC_MARKETPLACE_YML, - runner: FakeRunner | None = None, -) -> tuple[MarketplacePublisher, FakeRunner]: - """Create a publisher with a fake runner and marketplace.yml.""" - _write_marketplace_yml(tmp_path, yml_content) - if runner is None: - runner = FakeRunner() - publisher = MarketplacePublisher( - tmp_path, - clock=_fixed_clock, - runner=runner, - ) - return publisher, runner - - -# =================================================================== -# State file tests -# =================================================================== - - -class TestPublishState: - """Tests for the transactional state file manager.""" - - def test_load_missing_file_returns_fresh(self, tmp_path: Path) -> None: - state = PublishState.load(tmp_path) - assert state.data["schemaVersion"] == 1 - assert state.data["lastRun"] is None - assert state.data["history"] == [] - - def test_load_corrupt_file_returns_fresh(self, tmp_path: Path) -> None: - apm_dir = tmp_path / ".apm" - apm_dir.mkdir() - (apm_dir / "publish-state.json").write_text("not valid json {{{", encoding="utf-8") - state = PublishState.load(tmp_path) - assert state.data["schemaVersion"] == 1 - assert state.data["lastRun"] is None - - def test_begin_run_creates_apm_dir(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - assert (tmp_path / ".apm" / "publish-state.json").exists() - - def test_begin_run_writes_started_at(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - data = state.data - assert data["lastRun"]["startedAt"] is not None - assert data["lastRun"]["finishedAt"] is None - assert data["lastRun"]["marketplaceName"] == "acme-tools" - assert data["lastRun"]["marketplaceVersion"] == "2.0.0" - - def test_record_result_appends(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - - target = ConsumerTarget(repo="acme-org/svc-a") - result = TargetResult( - target=target, - outcome=PublishOutcome.UPDATED, - message="Updated to 2.0.0", - old_version="1.0.0", - new_version="2.0.0", - ) - state.record_result(result) - - results = state.data["lastRun"]["results"] - assert len(results) == 1 - assert results[0]["repo"] == "acme-org/svc-a" - assert results[0]["outcome"] == "updated" - - def test_record_result_without_begin_is_noop(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - target = ConsumerTarget(repo="acme-org/svc-a") - result = TargetResult( - target=target, - outcome=PublishOutcome.UPDATED, - message="test", - ) - # Should not raise - state.record_result(result) - assert state.data["lastRun"] is None - - def test_finalise_sets_finished_at(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - finished = datetime(2025, 1, 15, 12, 30, 0, tzinfo=timezone.utc) - state.finalise(finished) - assert state.data["lastRun"]["finishedAt"] == finished.isoformat() - - def test_finalise_rotates_history(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - finished = datetime(2025, 1, 15, 12, 30, 0, tzinfo=timezone.utc) - state.finalise(finished) - assert len(state.data["history"]) == 1 - assert state.data["history"][0]["marketplaceName"] == "acme-tools" - - def test_history_trimmed_at_10(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - for i in range(12): - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version=f"{i}.0.0", - targets=(), - commit_message="test", - branch_name=f"branch-{i}", - new_ref=f"v{i}.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - finished = datetime(2025, 1, 15, 12, i, 0, tzinfo=timezone.utc) - state.finalise(finished) - assert len(state.data["history"]) == 10 - # Most recent should be first - assert state.data["history"][0]["marketplaceVersion"] == "11.0.0" - - def test_abort_sets_marker(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - state.abort("network failure") - assert state.data["lastRun"]["finishedAt"].startswith("ABORTED:") - assert "network failure" in state.data["lastRun"]["finishedAt"] - - def test_round_trip_persistence(self, tmp_path: Path) -> None: - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - finished = datetime(2025, 1, 15, 12, 30, 0, tzinfo=timezone.utc) - state.finalise(finished) - - # Reload from disk - state2 = PublishState.load(tmp_path) - assert state2.data["lastRun"]["marketplaceName"] == "acme-tools" - assert len(state2.data["history"]) == 1 - - def test_atomic_write_no_partial_on_disk(self, tmp_path: Path) -> None: - """Verify the temp file is cleaned up after a successful write.""" - state = PublishState(tmp_path) - plan = PublishPlan( - marketplace_name="acme-tools", - marketplace_version="2.0.0", - targets=(), - commit_message="test", - branch_name="test-branch", - new_ref="v2.0.0", - tag_pattern_used="v{version}", - ) - state.begin_run(plan) - tmp_file = tmp_path / ".apm" / "publish-state.json.tmp" - assert not tmp_file.exists() - - -# =================================================================== -# plan() tests -# =================================================================== - - -class TestPublishPlan: - """Tests for MarketplacePublisher.plan().""" - - def test_plan_loads_yml_name_and_version(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - assert plan.marketplace_name == "acme-tools" - assert plan.marketplace_version == "2.0.0" - - def test_plan_deterministic_branch_name(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan1 = pub.plan(targets) - plan2 = pub.plan(targets) - assert plan1.branch_name == plan2.branch_name - assert plan1.branch_name.startswith("apm/marketplace-update-acme-tools-2.0.0-") - - def test_plan_hash_stable_across_calls(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ - ConsumerTarget(repo="acme-org/svc-a"), - ConsumerTarget(repo="acme-org/svc-b"), - ] - plan1 = pub.plan(targets) - plan2 = pub.plan(targets) - assert plan1.commit_message == plan2.commit_message - - def test_plan_hash_changes_with_target_package(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan1 = pub.plan(targets) - plan2 = pub.plan(targets, target_package="code-reviewer") - assert plan1.branch_name != plan2.branch_name - - def test_plan_commit_message_contains_trailer(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - assert "APM-Publish-Id:" in plan.commit_message - assert "chore(apm): bump acme-tools to 2.0.0" in (plan.commit_message) - - def test_plan_rejects_path_traversal(self, tmp_path: Path) -> None: - with pytest.raises(PathTraversalError): - ConsumerTarget( - repo="acme-org/svc-a", - path_in_repo="../etc/passwd", - ) - - def test_plan_rejects_dot_dot_path(self, tmp_path: Path) -> None: - with pytest.raises(PathTraversalError): - ConsumerTarget( - repo="acme-org/svc-a", - path_in_repo="../../secrets.yml", - ) - - def test_plan_stores_flags(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan( - targets, - allow_downgrade=True, - allow_ref_change=True, - ) - assert plan.allow_downgrade is True - assert plan.allow_ref_change is True - - def test_plan_branch_name_sanitised(self, tmp_path: Path) -> None: - """Marketplace names with spaces/special chars are sanitised.""" - yml = textwrap.dedent("""\ - name: "acme tools v2" - description: Tools - version: 1.0.0 - owner: - name: Acme Corp - """) - pub, _ = _make_publisher(tmp_path, yml_content=yml) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - # Spaces replaced with hyphens - assert " " not in plan.branch_name - assert "acme-tools-v2" in plan.branch_name - - def test_plan_hash_independent_of_target_order(self, tmp_path: Path) -> None: - """Hash is stable regardless of target ordering (repos sorted).""" - pub, _ = _make_publisher(tmp_path) - targets_a = [ - ConsumerTarget(repo="acme-org/svc-a"), - ConsumerTarget(repo="acme-org/svc-b"), - ] - targets_b = [ - ConsumerTarget(repo="acme-org/svc-b"), - ConsumerTarget(repo="acme-org/svc-a"), - ] - plan_a = pub.plan(targets_a) - plan_b = pub.plan(targets_b) - assert plan_a.branch_name == plan_b.branch_name - - def test_plan_computes_new_ref(self, tmp_path: Path) -> None: - """new_ref is computed via render_tag from tag_pattern.""" - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - assert plan.new_ref == "v2.0.0" - assert plan.tag_pattern_used == "v{version}" - - def test_plan_custom_tag_pattern(self, tmp_path: Path) -> None: - """Custom tag_pattern in marketplace.yml is honoured.""" - yml = textwrap.dedent("""\ - name: acme-tools - description: Tools - version: 3.1.0 - owner: - name: Acme Corp - build: - tagPattern: "{name}-v{version}" - """) - pub, _ = _make_publisher(tmp_path, yml_content=yml) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - assert plan.new_ref == "acme-tools-v3.1.0" - assert plan.tag_pattern_used == "{name}-v{version}" - - -# =================================================================== -# execute() tests -# =================================================================== - - -class TestExecuteHappyPath: - """Tests for MarketplacePublisher.execute() -- happy path.""" - - def test_execute_updates_single_entry(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert len(results) == 1 - assert results[0].outcome == PublishOutcome.UPDATED - assert results[0].old_version == "v1.0.0" - assert results[0].new_version == "v2.0.0" - - def test_execute_runs_git_add_commit_push(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - pub.execute(plan) - - add_calls = runner.git_calls("add") - commit_calls = runner.git_calls("commit") - push_calls = runner.git_calls("push") - assert len(add_calls) == 1 - assert len(commit_calls) == 1 - assert len(push_calls) == 1 - assert "apm.yml" in add_calls[0] - assert "-u" in push_calls[0] - - def test_execute_case_insensitive_match(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_CASE_INSENSITIVE, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_records_state(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - pub.execute(plan) - - state = PublishState.load(tmp_path) - assert state.data["lastRun"] is not None - assert state.data["lastRun"]["finishedAt"] is not None - assert len(state.data["lastRun"]["results"]) == 1 - - def test_execute_multiple_targets(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - runner.clone_files["acme-org/svc-b"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ - ConsumerTarget(repo="acme-org/svc-a"), - ConsumerTarget(repo="acme-org/svc-b"), - ] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert len(results) == 2 - assert all(r.outcome == PublishOutcome.UPDATED for r in results) - - def test_execute_multi_match_updates_all(self, tmp_path: Path) -> None: - """Multiple plugins from the same marketplace are all updated.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_MULTI_MATCH, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - assert "2" in results[0].message # "Updated 2 entries" - - def test_execute_ignores_direct_repo_refs(self, tmp_path: Path) -> None: - """Direct repo refs (owner/repo#ref) are not marketplace entries.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_MIXED, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - # Only the marketplace entry is updated; direct repo ref is ignored - assert results[0].outcome == PublishOutcome.UPDATED - - def test_execute_new_ref_computed_from_tag_pattern(self, tmp_path: Path) -> None: - """plan().new_ref is computed via render_tag from tag_pattern.""" - pub, _ = _make_publisher(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - assert plan.new_ref == "v2.0.0" - assert plan.tag_pattern_used == "v{version}" - - -class TestExecuteGuards: - """Tests for downgrade, ref-change, and no-change guards.""" - - def test_downgrade_guard_skips(self, tmp_path: Path) -> None: - """Consumer at v3.0.0, marketplace publishing v2.0.0 -> downgrade.""" - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v3.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.SKIPPED_DOWNGRADE - assert "Downgrade" in results[0].message - - def test_downgrade_guard_allowed(self, tmp_path: Path) -> None: - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v3.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets, allow_downgrade=True) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - - def test_ref_change_guard_implicit_latest(self, tmp_path: Path) -> None: - """Entry without #ref (implicit latest) triggers ref-change guard.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_NO_REF, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.SKIPPED_REF_CHANGE - assert "allow_ref_change" in results[0].message - - def test_ref_change_guard_implicit_allowed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_NO_REF, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets, allow_ref_change=True) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - - def test_ref_change_guard_branch_ref(self, tmp_path: Path) -> None: - """Non-semver old ref (branch name) + semver new ref -> guard.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_BRANCH_REF, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.SKIPPED_REF_CHANGE - assert "main" in results[0].message - - def test_ref_change_guard_sha_ref(self, tmp_path: Path) -> None: - """Non-semver old ref (SHA) + semver new ref -> guard.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_SHA_REF, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.SKIPPED_REF_CHANGE - assert "abc123def456" in results[0].message - - def test_ref_change_guard_branch_allowed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_BRANCH_REF, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets, allow_ref_change=True) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - - def test_no_change_identical_pin(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V2, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.NO_CHANGE - assert "Already at" in results[0].message - - def test_no_change_no_commit_no_push(self, tmp_path: Path) -> None: - """When ref is unchanged, no git commit or push should occur.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V2, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - pub.execute(plan) - - assert len(runner.git_calls("commit")) == 0 - assert len(runner.git_calls("push")) == 0 - - def test_downgrade_guard_any_entry_fires(self, tmp_path: Path) -> None: - """If ANY matching entry triggers downgrade, entire target skipped.""" - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v1.0.0 - - test-gen@acme-tools#v3.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.SKIPPED_DOWNGRADE - - -class TestExecuteMatching: - """Tests for marketplace name matching via parse_marketplace_ref.""" - - def test_not_found(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_NO_MATCH, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "not referenced" in results[0].message.lower() - - def test_empty_apm_list(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": "dependencies:\n apm: []\n", - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "not referenced" in results[0].message.lower() - - def test_missing_dependencies_key(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": "some_key: value\n", - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "not referenced" in results[0].message.lower() - - def test_missing_apm_key(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": "dependencies:\n npm: []\n", - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "not referenced" in results[0].message.lower() - - def test_only_direct_repo_refs_no_match(self, tmp_path: Path) -> None: - """All entries are direct repo refs -- no marketplace match.""" - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - microsoft/apm-sample-package#v1.0.0 - - acme-org/code-reviewer#v2.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - - def test_malformed_entry_warning_included(self, tmp_path: Path) -> None: - """Malformed entries (semver range) produce warnings but continue.""" - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v1.0.0 - - bad-plugin@acme-tools#^2.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - # The valid entry should still be matched and updated - assert results[0].outcome == PublishOutcome.UPDATED - - def test_malformed_only_entries_fails_with_warning(self, tmp_path: Path) -> None: - """All marketplace entries malformed -> FAILED with warnings.""" - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - bad@acme-tools#^2.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "warning" in results[0].message.lower() - - def test_non_string_entries_skipped(self, tmp_path: Path) -> None: - """Non-string entries in the list are silently skipped.""" - runner = FakeRunner() - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v1.0.0 - - 42 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - - -class TestExecuteDryRun: - """Tests for dry_run mode.""" - - def test_dry_run_no_push(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan, dry_run=True) - - assert results[0].outcome == PublishOutcome.UPDATED - assert len(runner.git_calls("push")) == 0 - - def test_dry_run_still_commits_locally(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - pub.execute(plan, dry_run=True) - - assert len(runner.git_calls("commit")) == 1 - - def test_dry_run_records_state(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - pub.execute(plan, dry_run=True) - - state = PublishState.load(tmp_path) - results = state.data["lastRun"]["results"] - assert len(results) == 1 - assert results[0]["outcome"] == "updated" - - -class TestExecuteErrorIsolation: - """Tests for error isolation between targets.""" - - def test_exception_in_one_target_does_not_abort_others(self, tmp_path: Path) -> None: - runner = FakeRunner() - # svc-a will fail (no files in clone) - runner.clone_files["acme-org/svc-a"] = {} - # svc-b will succeed - runner.clone_files["acme-org/svc-b"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ - ConsumerTarget(repo="acme-org/svc-a"), - ConsumerTarget(repo="acme-org/svc-b"), - ] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert len(results) == 2 - # One should fail, the other succeed - outcomes = {r.target.repo: r.outcome for r in results} - assert outcomes["acme-org/svc-a"] == PublishOutcome.FAILED - assert outcomes["acme-org/svc-b"] == PublishOutcome.UPDATED - - def test_clone_failure_recorded_as_failed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.fail_on.add("clone") - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "Clone failed" in results[0].message - - def test_push_failure_recorded_as_failed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - runner.fail_on.add("push") - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "Push failed" in results[0].message - - def test_commit_failure_recorded_as_failed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - runner.fail_on.add("commit") - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "Commit failed" in results[0].message - - def test_invalid_yaml_recorded_as_failed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": "{{invalid yaml", - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "parse" in results[0].message.lower() - - def test_file_not_found_recorded_as_failed(self, tmp_path: Path) -> None: - runner = FakeRunner() - # Clone creates the dir but no apm.yml - runner.clone_files["acme-org/svc-a"] = {} - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "not found" in results[0].message.lower() - - -class TestExecutePathSecurity: - """Tests for path security during execution.""" - - def test_path_traversal_in_repo_rejected_at_execute(self, tmp_path: Path) -> None: - """ConsumerTarget rejects traversal paths at construction time.""" - with pytest.raises(PathTraversalError, match="traversal"): - ConsumerTarget( - repo="acme-org/svc-a", - path_in_repo="../../../etc/passwd", - ) - - -class TestTokenRedaction: - """Tests for token redaction in error messages.""" - - def test_redact_token_in_stderr(self) -> None: - raw = ( - "fatal: authentication failed for " - "'https://x-access-token:ghp_FAKE123@github.com/acme/tools'" - ) - redacted = _redact_token(raw) - assert "ghp_FAKE123" not in redacted - assert "https://***@" in redacted - - def test_redact_token_no_token(self) -> None: - raw = "fatal: repository not found" - assert _redact_token(raw) == raw - - def test_clone_error_token_redacted(self, tmp_path: Path) -> None: - """Clone failure stderr with embedded token is redacted.""" - - class TokenRunner(FakeRunner): - def __call__(self, cmd, **kwargs): - self.calls.append((list(cmd), dict(kwargs))) - if cmd[1] == "clone" and kwargs.get("check"): - raise subprocess.CalledProcessError( - 128, - cmd, - stdout="", - stderr=( - "fatal: authentication failed for " - "'https://x-access-token:ghp_FAKE123" - "@github.com/acme/tools'" - ), - ) - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - runner = TokenRunner() - pub = MarketplacePublisher( - tmp_path, - clock=_fixed_clock, - runner=runner, - ) - _write_marketplace_yml(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "ghp_FAKE123" not in results[0].message - - def test_recorded_state_token_redacted(self, tmp_path: Path) -> None: - """State file result messages are also redacted.""" - - class TokenRunner(FakeRunner): - def __call__(self, cmd, **kwargs): - self.calls.append((list(cmd), dict(kwargs))) - if cmd[1] == "clone" and kwargs.get("check"): - raise subprocess.CalledProcessError( - 128, - cmd, - stdout="", - stderr=("https://x-access-token:ghp_SECRET@github.com/x/y"), - ) - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - - runner = TokenRunner() - pub = MarketplacePublisher( - tmp_path, - clock=_fixed_clock, - runner=runner, - ) - _write_marketplace_yml(tmp_path) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - pub.execute(plan) - - state = PublishState.load(tmp_path) - msg = state.data["lastRun"]["results"][0]["message"] - assert "ghp_SECRET" not in msg - - -# =================================================================== -# safe_force_push() tests -# =================================================================== - - -class TestRunGitEnv: - """Tests for _run_git() subprocess environment hardening.""" - - def test_git_terminal_prompt_disabled(self, tmp_path: Path) -> None: - """_run_git() must pass GIT_TERMINAL_PROMPT=0 and GIT_ASKPASS=echo.""" - pub, runner = _make_publisher(tmp_path) - pub._run_git(["git", "status"]) - - assert len(runner.calls) == 1 - _, kwargs = runner.calls[0] - env = kwargs.get("env", {}) - assert env.get("GIT_TERMINAL_PROMPT") == "0" - assert env.get("GIT_ASKPASS") == "echo" - - -class TestSafeForcePush: - """Tests for MarketplacePublisher.safe_force_push().""" - - def test_trailer_match_pushes(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.log_output = "chore(apm): bump acme-tools to 2.0.0\n\nAPM-Publish-Id: abc12345\n" - pub, _ = _make_publisher(tmp_path, runner=runner) - - result = pub.safe_force_push("origin", "apm/update-branch", "abc12345") - assert result is True - push_calls = runner.git_calls("push") - assert len(push_calls) == 1 - assert "--force-with-lease" in push_calls[0] - - def test_trailer_mismatch_refuses(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.log_output = ( - "chore(apm): bump acme-tools to 2.0.0\n\nAPM-Publish-Id: different-hash\n" - ) - pub, _ = _make_publisher(tmp_path, runner=runner) - - result = pub.safe_force_push("origin", "apm/update-branch", "abc12345") - assert result is False - push_calls = runner.git_calls("push") - assert len(push_calls) == 0 - - def test_no_trailer_refuses(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.log_output = "some random commit message\n" - pub, _ = _make_publisher(tmp_path, runner=runner) - - result = pub.safe_force_push("origin", "apm/update-branch", "abc12345") - assert result is False - - def test_git_log_failure_returns_false(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.fail_on.add("log") - pub, _ = _make_publisher(tmp_path, runner=runner) - - result = pub.safe_force_push("origin", "apm/update-branch", "abc12345") - assert result is False - - def test_push_failure_returns_false(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.log_output = "APM-Publish-Id: abc12345\n" - runner.fail_on.add("push") - pub, _ = _make_publisher(tmp_path, runner=runner) - - result = pub.safe_force_push("origin", "apm/update-branch", "abc12345") - assert result is False - - -# =================================================================== -# Data model tests -# =================================================================== - - -class TestDataModel: - """Tests for the data model classes.""" - - def test_consumer_target_defaults(self) -> None: - t = ConsumerTarget(repo="acme-org/svc-a") - assert t.branch == "main" - assert t.path_in_repo == "apm.yml" - - def test_consumer_target_frozen(self) -> None: - t = ConsumerTarget(repo="acme-org/svc-a") - with pytest.raises(AttributeError): - t.repo = "other" # type: ignore[misc] - - def test_publish_plan_frozen(self) -> None: - plan = PublishPlan( - marketplace_name="test", - marketplace_version="1.0.0", - targets=(), - commit_message="test", - branch_name="test", - new_ref="v1.0.0", - tag_pattern_used="v{version}", - ) - with pytest.raises(AttributeError): - plan.marketplace_name = "other" # type: ignore[misc] - - def test_target_result_frozen(self) -> None: - t = ConsumerTarget(repo="acme-org/svc-a") - result = TargetResult( - target=t, - outcome=PublishOutcome.UPDATED, - message="test", - ) - with pytest.raises(AttributeError): - result.message = "other" # type: ignore[misc] - - def test_publish_outcome_values(self) -> None: - assert PublishOutcome.UPDATED.value == "updated" - assert PublishOutcome.NO_CHANGE.value == "no-change" - assert PublishOutcome.SKIPPED_DOWNGRADE.value == ("skipped-downgrade") - assert PublishOutcome.SKIPPED_REF_CHANGE.value == ("skipped-ref-change") - assert PublishOutcome.FAILED.value == "failed" - - def test_publish_outcome_is_str(self) -> None: - """PublishOutcome(str, Enum) instances are also strings.""" - assert isinstance(PublishOutcome.UPDATED, str) - - -# =================================================================== -# Edge case tests -# =================================================================== - - -class TestEdgeCases: - """Miscellaneous edge cases.""" - - def test_non_dict_yaml_is_failed(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": "- just a list\n", - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "mapping" in results[0].message.lower() - - def test_dependencies_apm_not_a_list(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": "dependencies:\n apm: not-a-list\n", - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "not referenced" in results[0].message.lower() - - def test_custom_path_in_repo(self, tmp_path: Path) -> None: - """Targets can use a custom path for apm.yml.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "config/apm.yml": _CONSUMER_APM_YML_V1, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ - ConsumerTarget( - repo="acme-org/svc-a", - path_in_repo="config/apm.yml", - ) - ] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.UPDATED - - def test_results_preserved_in_target_order(self, tmp_path: Path) -> None: - """Results list matches plan.targets order, not completion order.""" - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - runner.clone_files["acme-org/svc-b"] = { - "apm.yml": _CONSUMER_APM_YML_V2, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ - ConsumerTarget(repo="acme-org/svc-a"), - ConsumerTarget(repo="acme-org/svc-b"), - ] - plan = pub.plan(targets) - results = pub.execute(plan, parallel=1) - - assert results[0].target.repo == "acme-org/svc-a" - assert results[1].target.repo == "acme-org/svc-b" - assert results[0].outcome == PublishOutcome.UPDATED - assert results[1].outcome == PublishOutcome.NO_CHANGE - - def test_semver_comparison_strips_v_prefix(self, tmp_path: Path) -> None: - """Downgrade guard strips leading 'v' for semver comparison.""" - runner = FakeRunner() - # Consumer pinned at v3.0.0, marketplace publishing v2.0.0 - consumer_yml = textwrap.dedent("""\ - dependencies: - apm: - - code-reviewer@acme-tools#v3.0.0 - """) - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": consumer_yml, - } - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.SKIPPED_DOWNGRADE - - def test_branch_checkout_failure(self, tmp_path: Path) -> None: - runner = FakeRunner() - runner.clone_files["acme-org/svc-a"] = { - "apm.yml": _CONSUMER_APM_YML_V1, - } - runner.fail_on.add("checkout") - pub, _ = _make_publisher(tmp_path, runner=runner) - targets = [ConsumerTarget(repo="acme-org/svc-a")] - plan = pub.plan(targets) - results = pub.execute(plan) - - assert results[0].outcome == PublishOutcome.FAILED - assert "Branch creation failed" in results[0].message - - -# =================================================================== -# S4: ConsumerTarget validation -# =================================================================== - - -class TestConsumerTargetValidation: - """Branch and repo fields on ConsumerTarget must be validated.""" - - def test_branch_with_dotdot_rejected(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="disallowed characters"): - ConsumerTarget( - repo="acme-org/svc-a", - branch="../malicious", - ) - - def test_branch_with_shell_metachar_rejected(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="disallowed characters"): - ConsumerTarget( - repo="acme-org/svc-a", - branch="main;rm -rf /", - ) - - def test_repo_with_shell_metachar_rejected(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="owner/name"): - ConsumerTarget( - repo="acme-org/svc-a;echo pwned", - ) - - def test_repo_invalid_format_rejected(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="owner/name"): - ConsumerTarget( - repo="not a valid repo", - ) - - def test_valid_target_passes(self, tmp_path: Path) -> None: - pub, _ = _make_publisher(tmp_path) - targets = [ - ConsumerTarget( - repo="acme-org/svc-a", - branch="main", - ) - ] - plan = pub.plan(targets) - assert plan.marketplace_name == "acme-tools"