diff --git a/CHANGELOG.md b/CHANGELOG.md index d007d98bc..9adade001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- [Experimental] `apm marketplace package add/set`: mutable git refs (`HEAD`, branch names) are now auto-resolved to concrete SHAs for supply-chain safety. When no `--ref` is provided, the current HEAD SHA is pinned automatically. (#790) +- [Experimental] `apm marketplace init` subcommand to scaffold a richly-commented `marketplace.yml` in the current directory, with an optional `.gitignore` staleness check (#790) +- [Experimental] `apm marketplace build` subcommand to compile `marketplace.yml` into an Anthropic-compliant `marketplace.json` with `--dry-run`, `--offline`, and `--include-prerelease` flags; APM-only build options are stripped and `metadata:` is passed through verbatim (#790) +- [Experimental] `apm marketplace outdated` subcommand to report upgradable package versions, distinguishing "latest in range" from "latest overall" so maintainers know when a manual range bump is required (#790) +- [Experimental] `apm marketplace check` subcommand to validate `marketplace.yml` and verify every package entry resolves (`--offline` for schema + cached-ref checks) (#790) +- [Experimental] `apm marketplace doctor` subcommand for environment diagnostics (git, network, auth, `gh` CLI, and `marketplace.yml` readiness) (#790) +- [Experimental] `apm marketplace publish` subcommand to open PRs across consumer repositories from a `consumer-targets.yml`, with `--dry-run`, `--no-pr`, `--draft`, `--allow-downgrade`, `--allow-ref-change`, `--parallel N`, and a `.apm/publish-state.json` run history (#790) +- [Experimental] `apm marketplace package add|set|remove` subcommands for programmatic management of marketplace.yml entries (#790) + +### Changed + +- [Experimental] Renamed `apm marketplace plugin` subgroup to `apm marketplace package` for npm/pip/cargo familiarity (#722) +- [Experimental] Grouped `apm marketplace --help` output into "Consumer commands" and "Authoring commands" sections (#722) +- [Experimental] `apm marketplace init` now accepts `--name` and `--owner` flags for non-interactive scaffolding (#722) + ### Fixed - Docs site auto-deploys again after bot-cut releases by correctly detecting tag-push context in `docs.yml`. (#953) +- [Experimental] Hidden unimplemented `--check-refs` flag on `validate` command (#722) +- [Experimental] Fixed `includePrerelease` camelCase typo in init template comment (#722) +- [Experimental] `apm marketplace doctor` now uses `AuthResolver` for GitHub token detection instead of raw env-var lookup (#790) +- [Experimental] `apm marketplace doctor` checks `gh` CLI availability as an informational diagnostic (#790) +- [Experimental] `apm marketplace outdated` summary line simplified; exit code 1 when upgradable packages exist (#790) +- [Experimental] `Builder.resolve()` returns a `ResolveResult` dataclass instead of smuggling errors via instance state (#790) +- [Experimental] `ConsumerTarget` validates repo format, branch safety, and path traversal at construction time (shift-left) (#790) +- [Experimental] `apm marketplace` confirmation prompts now fail loudly in non-interactive/CI mode without `--yes` (#790) +- [Experimental] `apm marketplace` exception handlers log verbose tracebacks via `logger.debug(exc_info=True)` and three handlers narrowed from bare `Exception` (#790) +- [Experimental] Replaced 15 bare `click.echo()` calls and 3 Rich markup literals with `CommandLogger` methods (#790) +- [Experimental] `version_pins.load_ref_pins()` warns when an expected pin file is missing instead of silently returning empty (#790) ### Maintainer tooling @@ -23,6 +51,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Day-0 install parity with `npx skills add`**: every public repo that installs cleanly with `npx skills add owner/repo` now installs with `apm install owner/repo`. APM recognises bare `skills//SKILL.md` (vercel-labs/agent-skills, xixu-me/skills, larksuite/cli, the agentskills.io ecosystem) as a first-class shape (`SKILL_BUNDLE`); `apm.yml` is optional. `--skill ` (repeatable) selects a subset and **persists** it to `apm.yml` + `apm.lock.yaml`, so bare `apm install` is reproducible across machines. `--skill '*'` resets; `apm audit --ci` flags drift. (#974) - `curl | sh` install works in air-gapped, GHE, and internal-mirror setups: `install.sh` now reads `APM_INSTALL_DIR`, `GITHUB_URL`, `APM_REPO`, and `VERSION` (or `@vX.Y.Z` arg) -- pinning a version skips the GitHub API entirely, so corporate runners without api.github.com egress can bootstrap APM. (#660) +### Changed + +- `apm marketplace` authoring commands (init, build, check, outdated, doctor, publish, package) ring-fenced behind `apm experimental enable marketplace-authoring` feature flag (default: disabled) (#790) ### Fixed - `apm install` no longer fails behind corporate TLS-intercepting proxies: validation now honours `REQUESTS_CA_BUNDLE` instead of misreporting CA failures as auth errors. (#911) @@ -82,6 +113,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enterprise docs IA refactor: hub page + merged team guides, deduped governance content. (#858) - Landing page rewritten around the three-pillar spine. (#855) - First-package tutorial rewritten end-to-end; fixes `.apm/` anatomy hallucinations. (#866) +- `apm install --ssh` / `--https` flags and `APM_GIT_PROTOCOL=ssh|https` env to pick the initial transport for shorthand dependencies (#778) +- `apm install --allow-protocol-fallback` flag and `APM_ALLOW_PROTOCOL_FALLBACK=1` env as the migration escape hatch for cross-protocol fallback (#778) +- Add APM Review Panel skill (`.github/skills/apm-review-panel/`) and four new specialist personas (`devx-ux-expert`, `supply-chain-security-expert`, `apm-ceo`, `oss-growth-hacker`) with auto-activating per-persona skills. Routes specialist findings through an APM CEO arbiter for strategic / breaking-change calls, with the OSS growth hacker side-channeling adoption insights via `WIP/growth-strategy.md`. Instrumentation per Handbook Ch. 9 (`The Instrumented Codebase`); PROSE-compliant (thin SKILL.md routers, persona detail lazy-loaded via markdown links, explicit boundaries per persona). +- `apm view plugin@marketplace` displays marketplace plugin metadata (name, version, source, description) (#514) +- `apm outdated` checks marketplace plugin refs and shows a "Source" column distinguishing marketplace vs git updates (#514) +- `apm marketplace validate` command with schema validation and duplicate name detection (#514) +- Ref immutability advisory: caches plugin-to-ref pins and warns when a previously pinned plugin's ref changes (#514) +- Multi-marketplace shadow detection: warns when the same plugin name appears in multiple registered marketplaces (#514) ### Changed diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 2aa97ebf3..3a8229bd8 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -76,6 +76,7 @@ export default defineConfig({ { label: 'Private Packages', slug: 'guides/private-packages' }, { label: 'Org-Wide Packages', slug: 'guides/org-packages' }, { label: 'Marketplaces', slug: 'guides/marketplaces' }, + { label: 'Marketplace Authoring', slug: 'guides/marketplace-authoring' }, { label: 'CI Policy Enforcement', slug: 'guides/ci-policy-setup' }, { label: 'Agent Workflows (Experimental)', slug: 'guides/agent-workflows' }, ], diff --git a/docs/src/content/docs/guides/marketplace-authoring.md b/docs/src/content/docs/guides/marketplace-authoring.md new file mode 100644 index 000000000..d30aa8ef0 --- /dev/null +++ b/docs/src/content/docs/guides/marketplace-authoring.md @@ -0,0 +1,391 @@ +--- +title: "Authoring a marketplace" +description: Create and maintain an APM marketplace that stays in sync with Anthropic's marketplace.json standard. +sidebar: + order: 6 +--- + +This guide is for **marketplace maintainers** -- the people who curate a set of plugin packages for their team or organisation. If you are a consumer installing plugins from an existing marketplace, see the [Marketplaces guide](../marketplaces/) instead. + +APM gives you a two-file authoring model: + +- `marketplace.yml` -- source of truth, hand-edited, expressive (version ranges, tag patterns, prereleases). +- `marketplace.json` -- compiled artefact, byte-for-byte compliant with Anthropic's `marketplace.json` standard, consumed by Claude Code, Copilot CLI, and APM itself. + +Both files are committed to git. `marketplace.yml` is edited; `marketplace.json` is regenerated with `apm marketplace build`. + +## Anthropic compliance + +`marketplace.json` produced by `apm marketplace build` conforms to [Anthropic's marketplace.json specification](https://docs.claude.com/en/docs/claude-code/plugin-marketplaces). The compiler follows three rules: + +1. **`plugins:` is emitted verbatim.** APM does not rename, reorder, or decorate plugin entries. The Anthropic-defined key name (`plugins`) is used as-is. +2. **`metadata:` is an opaque pass-through.** Whatever you put under `metadata:` in `marketplace.yml` is copied byte-for-byte into `marketplace.json`, preserving key casing (for example, `pluginRoot` stays `pluginRoot`). This means extensions to Anthropic's schema (new metadata fields) usually do not need an APM code change. +3. **APM-only fields are stripped at compile time.** The `build:` block, per-package `version` ranges, `tagPattern` overrides, and `includePrerelease` flags live only in `marketplace.yml`. They never leak into `marketplace.json`. + +APM does not emit a `versions[]` array. Each compiled plugin has exactly one resolved `source.ref` -- the latest commit SHA (or explicit ref) that satisfies the declared range at build time. Consumers pin to that single resolved ref. + +:::caution[Experimental Feature] +Marketplace authoring commands are behind an experimental flag. Enable it once before following this guide: + +```bash +apm experimental enable marketplace-authoring +``` + +See [Experimental Flags](../../reference/experimental/) for details. +::: + +## Quickstart + +```bash +# 1. Scaffold a marketplace.yml +apm marketplace init + +# 2. Edit marketplace.yml -- add your packages, owner, metadata +$EDITOR marketplace.yml + +# 3. Compile to marketplace.json +apm marketplace build + +# 4. Commit BOTH files +git add marketplace.yml marketplace.json +git commit -m "Initial marketplace" +git push +``` + +Consumers now register your repository with `apm marketplace add /` and install packages from it. + +## The marketplace.yml schema + +Full example: + +```yaml +name: my-marketplace +description: Curated plugins for the acme-org engineering team +version: 1.2.0 + +owner: + name: acme-org + url: https://github.com/acme-org + email: maintainers@acme-org.example + +# APM-only: stripped from marketplace.json at compile time. +build: + tagPattern: "v{version}" + +# Pass-through: copied verbatim into marketplace.json. +metadata: + homepage: https://example.com/plugins + pluginRoot: ./plugins + +packages: + - name: example-package + description: Example package consumers will see + source: acme-org/example-package + version: "^1.0.0" + + - name: monorepo-tool + description: Package that lives in a subdirectory + source: acme-org/monorepo + subdir: tools/monorepo-tool + version: "~2.3.0" + tagPattern: "monorepo-tool-v{version}" + + - name: pinned-package + description: Pinned to an explicit ref + source: acme-org/pinned-package + ref: 3f2a9b1c +``` + +### Top-level fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | yes | Marketplace identifier. | +| `description` | yes | One-line summary shown to consumers. | +| `version` | yes | Semver of the marketplace itself. Bump on release. | +| `owner` | yes | Mapping with `name` (required), optional `url`, `email`. | +| `output` | no | Output path for the compiled file. Defaults to `marketplace.json`. | +| `build` | no | APM-only build options. See below. | +| `metadata` | no | Opaque pass-through copied into `marketplace.json`. | +| `packages` | no | List of package entries. | + +### The `build` block (APM-only) + +| Field | Default | Description | +|-------|---------|-------------| +| `tagPattern` | `v{version}` | Marketplace-wide default for resolving `{version}` to a git tag. Accepts `{version}` and `{name}` placeholders. | + +Stripped from `marketplace.json` at compile time. + +### Package entries + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | yes | Plugin name consumers will install. Unique within the marketplace. | +| `source` | yes | `/` shape, e.g. `acme-org/example-package`. Resolves to a git remote. | +| `description` | no | Pass-through to `marketplace.json`. | +| `tags` | no | Pass-through list of strings. | +| `version` | conditional | Semver range (see below). Either `version` or `ref` must be set. | +| `ref` | conditional | Explicit SHA, tag, or branch. Takes precedence over `version`. | +| `subdir` | no | Subdirectory within the repo. Validated against path traversal. | +| `tag_pattern` | no | Per-package override of `build.tagPattern`. | +| `include_prerelease` | no | Include semver pre-release tags in range resolution. Defaults to `false`. | + +Unknown keys at any level raise a schema error rather than being silently ignored. + +### `.gitignore` + +Both `marketplace.yml` and `marketplace.json` must be tracked. `apm marketplace init` warns if your `.gitignore` would exclude `marketplace.json`. If you use a generic `*.json` rule, add an explicit unignore: + +```gitignore +# .gitignore +*.json +!marketplace.json +``` + +## Version ranges + +APM uses npm-compatible semver ranges. The most common forms: + +| Range | Matches | +|-------|---------| +| `1.2.3` | Exact version. | +| `^1.2.3` | Compatible: `>=1.2.3 <2.0.0`. | +| `~1.2.3` | Patch-level: `>=1.2.3 <1.3.0`. | +| `>=1.2.0` | Everything from 1.2.0 upwards. | +| `<2.0.0` | Everything below 2.0.0. | +| `1.x` or `1.*` | Any 1.y.z. | +| `>=1.2.0 <2.0.0` | AND-combination. | + +Pre-release tags (for example `1.2.0-beta.1`) are excluded by default. Set `include_prerelease: true` on the entry, or pass `--include-prerelease` to the build command, to include them. + +Pin to a non-semver ref when you need exact reproducibility across a range the upstream does not tag cleanly: + +```yaml +packages: + - name: pinned-package + source: acme-org/pinned-package + ref: 3f2a9b1cdeadbeef # SHA, tag, or branch -- overrides version ranges +``` + +`ref` takes precedence over `version`. If both are set, `version` is ignored. + +## Managing plugins + +Three subcommands let you manage `marketplace.yml` entries without hand-editing YAML. + +### Adding a package + +```bash +apm marketplace package add microsoft/apm-sample-package \ + --version ">=1.0.0" \ + --description "Sample package" +``` + +`package add` takes a `/` source, derives the package name from the repo, and appends an entry to `packages:`. Pass `--name` to override the derived name, `--subdir` for monorepo paths, `--tag-pattern` for non-default tag layouts, or `--tags` to attach metadata tags. By default the command verifies the source is reachable via `git ls-remote`; pass `--no-verify` to skip that check. + +`--version` and `--ref` are mutually exclusive -- use `--ref` to pin an exact SHA, tag, or branch instead of a semver range. + +### Updating a package + +```bash +apm marketplace package set apm-sample-package --version ">=2.0.0" +``` + +`package set` takes the package name (not the source) and updates the specified fields in place. Any option accepted by `package add` (except `--name`) can be passed to `package set`. + +### Removing a package + +```bash +apm marketplace package remove apm-sample-package --yes +``` + +`package remove` drops the named entry from `packages:`. Without `--yes` the command prompts for confirmation. + +## The build flow + +`apm marketplace build` reads `marketplace.yml`, runs `git ls-remote` against each package source, picks the best-matching ref for each entry, and writes `marketplace.json` atomically (temp file plus rename). + +``` +apm marketplace build +``` + +| Flag | Description | +|------|-------------| +| `--dry-run` | Resolve and print the result table, but do not write `marketplace.json`. | +| `--offline` | Use only cached refs; fail entries that need a fresh `git ls-remote`. | +| `--include-prerelease` | Allow pre-release tags to satisfy every range (overrides per-entry flag). | +| `-v`, `--verbose` | Include per-entry resolution detail. | + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Build succeeded; `marketplace.json` written (or previewed). | +| `1` | Build error -- network failure, ref not found, no tag matches the range, etc. | +| `2` | Schema error in `marketplace.yml`. | + +### What the compiler does + +1. Parses and validates `marketplace.yml`. Unknown keys or invalid semver is a schema error (exit 2). +2. For each package: runs `git ls-remote`, enumerates tags and branches, filters by the entry's tag pattern, resolves the version range, picks the highest match. +3. Walks `metadata:` unchanged into the output. +4. Emits `plugins:` with the Anthropic key name; each entry carries the resolved `source` (with `ref` and SHA) plus any pass-through fields (`description`, `tags`). +5. Writes the file atomically. + +## Checking and troubleshooting + +Two commands cover diagnosis. + +### `apm marketplace check` + +Validates the yml schema and verifies every entry is resolvable. Use it in CI before publishing. + +```bash +apm marketplace check +apm marketplace check --offline # schema + cached refs only +``` + +Exit code is non-zero when any entry is unreachable, a ref does not exist, or no tag satisfies a range. + +### `apm marketplace doctor` + +Checks the environment -- git version, network reachability of common hosts, `gh` CLI presence, git authentication, and whether `marketplace.yml` is present and parses. + +```bash +apm marketplace doctor +``` + +Run it first when `build` or `publish` fails in an unfamiliar environment. + +### Common errors + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `'packages[0].source' must match '/' shape` | `source` is a full URL or contains a path. | Use `owner/repo` and put path under `subdir:`. | +| `No tag matching '^1.0.0'` | No published tags satisfy the range under your tag pattern. | Loosen the range, check `tagPattern`, or pin with `ref:`. | +| `Ref 'main' not found` | Branch or tag does not exist upstream. | Verify with `git ls-remote `. | +| `Pre-release tags skipped` | Latest published tag is a pre-release. | Set `include_prerelease: true` on the entry or pass `--include-prerelease`. | +| `No cached refs (offline)` | First-ever `--offline` build. | Run once online to populate the cache, then retry offline. | +| `git ls-remote` auth failure | Private source without credentials. | Ensure your git credentials (SSH agent or `gh auth login`) can reach the source repo. | + +## Discovering upgrades + +`apm marketplace outdated` compares the currently resolved version of each package (as captured in `marketplace.json`) against the latest tag available in the source repo. + +```bash +apm marketplace outdated +apm marketplace outdated --include-prerelease +apm marketplace outdated --offline +``` + +Output columns: package, current version, declared range, latest in range, latest overall. Packages whose "latest overall" exceeds "latest in range" need a **manual range bump** (for example, widening `^1.0.0` to `^2.0.0`) before a new build will pick them up. This is intentional -- major-version bumps are a maintainer decision. + +Packages pinned with `ref:` show `--` in the range columns; `outdated` cannot reason about them. + +## Publishing to consumers + +`apm marketplace publish` drives the compiled `marketplace.json` out to consumer repositories and opens pull requests on their behalf. It is the end-to-end flow for "I just built a new marketplace version; roll it out." + +You need: + +1. A built `marketplace.json` on the current branch (run `apm marketplace build` first). +2. A `consumer-targets.yml` file listing the repos to update. +3. The [`gh` CLI](https://cli.github.com/) authenticated against GitHub (unless you use `--no-pr`). + +### The targets file + +```yaml +# consumer-targets.yml +targets: + - repo: acme-org/service-a + branch: main + - repo: acme-org/service-b + branch: develop + path_in_repo: apm/apm.yml # optional; defaults to apm.yml + - repo: acme-org/service-c + branch: main +``` + +`repo` and `branch` are required; `path_in_repo` defaults to `apm.yml`. Paths are validated for traversal. + +### First run -- preview + +Always dry-run first: + +```bash +apm marketplace publish --dry-run --yes +``` + +This clones each target, computes what would change in its lockfile references, and prints a plan. Nothing is pushed. + +### Real run + +```bash +apm marketplace publish +``` + +Output shows per-target status: updated, unchanged, failed. PR URLs are printed for each target that had changes. + +### Useful flags + +| Flag | Purpose | +|------|---------| +| `--targets PATH` | Use a custom targets file (default `./consumer-targets.yml`). | +| `--dry-run` | Preview; no push, no PR. | +| `--no-pr` | Push the branch to each target but skip PR creation (useful when `gh` is unavailable or you use another PR workflow). | +| `--draft` | Open PRs as drafts. | +| `--allow-downgrade` | Allow pushing a lower version than the target currently references. Off by default to prevent accidental regressions. | +| `--allow-ref-change` | Allow switching ref types (for example, branch to SHA). Off by default. | +| `--parallel N` | Maximum concurrent targets. Default `4`. | +| `--yes`, `-y` | Skip interactive confirmation (required for non-interactive CI). | +| `-v`, `--verbose` | Per-target detail. | + +### State file + +Publish runs append to `.apm/publish-state.json`, which records the history of runs (timestamps, targets, outcomes, PR URLs). This lets later invocations detect already-open PRs and avoid opening duplicates. The file is safe to commit or to gitignore -- it is advisory, not authoritative. + +## Recipes + +### Custom tag pattern + +Projects that prefix tags with a package name (common in monorepos) need a per-entry pattern: + +```yaml +packages: + - name: ui-components + source: acme-org/frontend-monorepo + subdir: packages/ui-components + version: "^3.0.0" + tag_pattern: "ui-components-v{version}" +``` + +The `{name}` placeholder resolves to the package entry's `name`, so you can also write `tag_pattern: "{name}-v{version}"` and reuse a single `build.tagPattern`. + +### Pre-release tags are being skipped + +Set `include_prerelease: true` on the package entry, or pass `--include-prerelease` to `build` and `outdated` for the whole marketplace: + +```yaml +packages: + - name: example-package + source: acme-org/example-package + version: ">=1.0.0-0" + include_prerelease: true +``` + +Note the `-0` pre-release suffix on the range -- it makes the lower bound inclusive of pre-releases. + +### PR body is wrong -- how do I re-run safely? + +Close the incorrect PR, fix `marketplace.yml` or the targets file, rebuild, and re-run `apm marketplace publish`. The command is idempotent on identical inputs: if the target branch already carries the expected change, the target is reported as "unchanged". If you need to force a fresh PR on a target that currently has a different ref than expected, pass `--allow-ref-change`. + +### Can I use a non-GitHub host? + +Not in the first release. `apm marketplace publish` uses the `gh` CLI and assumes GitHub for PR creation. You can still `build` and `check` against any git remote that speaks `git ls-remote` over HTTPS or SSH; only the `publish` step is GitHub-specific. For non-GitHub consumers, run `publish --no-pr` and drive the PR creation through your own tooling. + +## Related reading + +- [Marketplaces guide](../marketplaces/) -- consumer-side: registering and installing from a marketplace. +- [CLI command reference](../../reference/cli-commands/) -- authoritative options for every `apm marketplace` subcommand. +- [Plugins guide](../plugins/) -- what a plugin is and how consumers install one. diff --git a/docs/src/content/docs/guides/marketplaces.md b/docs/src/content/docs/guides/marketplaces.md index 0d160f3e3..71a1ea77e 100644 --- a/docs/src/content/docs/guides/marketplaces.md +++ b/docs/src/content/docs/guides/marketplaces.md @@ -86,6 +86,11 @@ apm marketplace add acme/plugin-marketplace This registers the marketplace and fetches its `marketplace.json`. By default APM tracks the `main` branch. +:::tip[Create your own marketplace] +You can author and publish your own marketplace registry. +See the [Marketplace Authoring Guide](../marketplace-authoring/) for details. +::: + **Options:** - `--name/-n` -- Custom display name for the marketplace - `--branch/-b` -- Branch to track (default: `main`) @@ -258,3 +263,55 @@ Shadow detection runs automatically during install -- no configuration required. - **Use commit SHAs as refs** -- tags and branches can be moved; commit SHAs cannot. - **Keep plugin names unique across marketplaces** -- avoids shadow warnings and reduces confusion. - **Review immutability warnings** -- a changed ref for an existing version is a strong signal of tampering. + +## Authoring: monorepo workflows + +When building a marketplace that tracks packages from a monorepo (multiple packages inside one Git repository), use `--subdir` to point each entry at its subdirectory: + +```bash +apm marketplace package add acme/monorepo --subdir plugins/eslint-rules --name eslint-rules +apm marketplace package add acme/monorepo --subdir plugins/formatter --name formatter +``` + +### Ref auto-resolution + +Mutable git refs (`HEAD`, branch names) are automatically resolved to concrete 40-character SHAs before being stored in `marketplace.yml`. This ensures supply-chain safety -- the entry always pins to an immutable commit. + +**Default behaviour (no `--ref`):** When neither `--version` nor `--ref` is provided, the current `HEAD` SHA is pinned automatically: + +```bash +# Resolves HEAD to its current SHA and stores it +apm marketplace package add acme/code-review +``` + +**Explicit `HEAD`:** Passing `--ref HEAD` warns that HEAD is mutable, then resolves: + +```bash +apm marketplace package add acme/code-review --ref HEAD +# [!] 'HEAD' is a mutable ref. Resolving to current SHA for safety. +# [i] Resolved HEAD to abc123def456 +``` + +**Branch names:** Branch names that match `refs/heads/*` on the remote are also resolved: + +```bash +apm marketplace package add acme/code-review --ref main +# [!] 'main' is a branch (mutable ref). Resolving to current SHA for safety. +# [i] Resolved main to abc123def456 +``` + +**Updating pinned SHAs:** Use `package set` with `--ref HEAD` to re-pin to the latest commit: + +```bash +apm marketplace package set code-review --ref HEAD +``` + +Tags and concrete SHAs are stored as-is without resolution. + +:::note +Ref auto-resolution requires network access. When using `--no-verify`, you must provide an explicit SHA with `--ref`. +::: + +## Creating your own marketplace + +If you want to create and maintain your own marketplace registry, see the [Marketplace Authoring Guide](../../guides/marketplace-authoring/). diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 260d4f403..25f5ea1ab 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -1257,6 +1257,264 @@ apm marketplace validate acme-plugins apm marketplace validate acme-plugins --verbose ``` +#### `apm marketplace init` - Scaffold a marketplace.yml + +Create a richly-commented `marketplace.yml` in the current directory. The scaffold is valid against the schema and ready to be edited. See the [Authoring a marketplace guide](../../guides/marketplace-authoring/). + +```bash +apm marketplace init [OPTIONS] +``` + +**Options:** +- `--force` - Overwrite an existing `marketplace.yml` +- `--no-gitignore-check` - Skip the `.gitignore` staleness check +- `-v, --verbose` - Show detailed output + +**Exit codes:** +- `0` - Scaffold written +- `1` - File already exists (without `--force`) or write failure + +**Examples:** +```bash +apm marketplace init +apm marketplace init --force +``` + +#### `apm marketplace build` - Compile marketplace.yml + +Resolve all package version ranges against the source repositories and write an Anthropic-compliant `marketplace.json`. APM-only fields (`build:`, version ranges, tag patterns) are stripped; `metadata:` is passed through verbatim. + +```bash +apm marketplace build [OPTIONS] +``` + +**Options:** +- `--dry-run` - Resolve and print the result table, but do not write `marketplace.json` +- `--offline` - Use cached refs only (no `git ls-remote` calls) +- `--include-prerelease` - Allow pre-release tags to satisfy ranges +- `-v, --verbose` - Per-entry resolution detail + +**Exit codes:** +- `0` - Build succeeded (or dry run complete) +- `1` - Build error (network failure, unresolvable ref, no matching tag) +- `2` - Schema error in `marketplace.yml` + +**Examples:** +```bash +# Compile marketplace.yml -> marketplace.json +apm marketplace build + +# Preview without writing +apm marketplace build --dry-run + +# Offline build against cached refs +apm marketplace build --offline +``` + +#### `apm marketplace outdated` - Report available upgrades + +List packages in `marketplace.yml` whose source repositories have newer tags available. Range-aware: distinguishes "latest in range" (picked up by next `build`) from "latest overall" (requires a manual range bump). + +```bash +apm marketplace outdated [OPTIONS] +``` + +**Options:** +- `--offline` - Use cached refs only +- `--include-prerelease` - Include pre-release tags +- `-v, --verbose` - Show detailed output + +**Exit codes:** +- `0` - Report rendered (even if upgrades are available) +- `1` - Unable to query refs +- `2` - Schema error in `marketplace.yml` + +**Examples:** +```bash +apm marketplace outdated +apm marketplace outdated --include-prerelease +``` + +#### `apm marketplace check` - Validate marketplace.yml entries + +Validate the `marketplace.yml` schema and verify that every package entry is resolvable (ref exists, at least one tag satisfies the range). Intended for CI use before publishing. + +```bash +apm marketplace check [OPTIONS] +``` + +**Options:** +- `--offline` - Schema and cached-ref checks only (no network) +- `-v, --verbose` - Show detailed output + +**Exit codes:** +- `0` - All entries OK +- `1` - One or more entries are unreachable or unresolvable +- `2` - Schema error in `marketplace.yml` + +**Examples:** +```bash +apm marketplace check +apm marketplace check --offline +``` + +#### `apm marketplace doctor` - Environment diagnostics + +Check git, network reachability, authentication, `gh` CLI availability, and the presence of `marketplace.yml`. Run this first when `build` or `publish` fails in an unfamiliar environment. + +```bash +apm marketplace doctor [OPTIONS] +``` + +**Options:** +- `-v, --verbose` - Per-check detail + +**Exit codes:** +- `0` - All checks pass +- `1` - One or more checks failed + +**Examples:** +```bash +apm marketplace doctor +apm marketplace doctor --verbose +``` + +#### `apm marketplace publish` - Open PRs on consumer repositories + +Drive the compiled `marketplace.json` out to consumer repositories listed in a `consumer-targets.yml` file, opening a pull request on each. Requires an authenticated `gh` CLI unless `--no-pr` is used. See the [Authoring a marketplace guide](../../guides/marketplace-authoring/#publishing-to-consumers) for the full workflow. + +```bash +apm marketplace publish [OPTIONS] +``` + +**Options:** +- `--targets PATH` - Path to the targets file (default: `./consumer-targets.yml`) +- `--dry-run` - Preview without pushing or opening PRs +- `--no-pr` - Push branches but skip PR creation +- `--draft` - Create PRs as drafts +- `--allow-downgrade` - Allow pushing a lower version than the target currently references +- `--allow-ref-change` - Allow switching ref types (for example, branch to SHA) +- `--parallel N` - Maximum concurrent target updates (default: `4`) +- `-y, --yes` - Skip the confirmation prompt (required in non-interactive sessions) +- `-v, --verbose` - Per-target detail + +**Exit codes:** +- `0` - All targets succeeded (or were already up to date) +- `1` - One or more targets failed, or prerequisites missing + +**Examples:** +```bash +# Preview the publish plan +apm marketplace publish --dry-run --yes + +# Publish with PRs +apm marketplace publish + +# Push branches only (no gh CLI needed) +apm marketplace publish --no-pr +``` + +Run history and PR URLs are recorded in `.apm/publish-state.json` so re-runs can detect existing PRs. + +#### `apm marketplace package add` - Add a package entry + +Add a package entry to `marketplace.yml`. + +```bash +apm marketplace package add SOURCE [OPTIONS] +``` + +**Arguments:** +- `SOURCE` - GitHub `owner/repo` reference + +**Options:** +- `--version TEXT` - Semver range constraint (e.g. `">=1.0.0"`) +- `--ref TEXT` - Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA +- `-d`, `--description TEXT` - Short description for the entry +- `-s`, `--subdir TEXT` - Subdirectory inside source repo +- `--include-prerelease` - Include pre-release versions +- `--no-verify` - Skip remote repository verification +- `--verbose` - Enable verbose output + +`--version` and `--ref` are mutually exclusive. When neither is provided, the current `HEAD` SHA is pinned automatically. + +**Examples:** +```bash +# Add a package with a version range +apm marketplace package add acme/code-review --version ">=1.0.0" + +# Pin to a specific tag +apm marketplace package add acme/code-review --ref v2.1.0 + +# Pin to current HEAD (auto-resolved to SHA) +apm marketplace package add acme/code-review + +# Add with description and skip verification (requires explicit --ref SHA) +apm marketplace package add acme/code-review --ref abc123...40chars \ + --description "Code review skill" --no-verify +``` + +#### `apm marketplace package set` - Update a package entry + +Update fields on an existing package entry in `marketplace.yml`. + +```bash +apm marketplace package set NAME [OPTIONS] +``` + +**Arguments:** +- `NAME` - Name of the existing package entry + +**Options:** +- `--version TEXT` - New semver range constraint +- `--ref TEXT` - New git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA +- `--description TEXT` - New description +- `--include-prerelease` - Enable pre-release version inclusion +- `--verbose` - Enable verbose output + +`--version` and `--ref` are mutually exclusive. At least one field option must be specified. + +**Examples:** +```bash +# Widen the version range +apm marketplace package set code-review --version ">=2.0.0" + +# Switch from version to pinned ref +apm marketplace package set code-review --ref abc1234 + +# Re-pin to current HEAD SHA +apm marketplace package set code-review --ref HEAD + +# Update the description +apm marketplace package set code-review --description "Updated review skill" +``` + +#### `apm marketplace package remove` - Remove a package entry + +Remove a package entry from `marketplace.yml`. + +```bash +apm marketplace package remove NAME [OPTIONS] +``` + +**Arguments:** +- `NAME` - Name of the package entry to remove + +**Options:** +- `--yes` - Skip confirmation prompt +- `--verbose` - Enable verbose output + +Prompts for confirmation unless `--yes` is passed. In non-interactive environments (CI), use `--yes`. + +**Examples:** +```bash +# Remove with confirmation prompt +apm marketplace package remove code-review + +# Skip confirmation (CI-friendly) +apm marketplace package remove code-review --yes +``` + ### `apm search` - Search plugins in a marketplace Search for plugins by name or description within a specific marketplace. diff --git a/docs/src/content/docs/reference/experimental.md b/docs/src/content/docs/reference/experimental.md index d34dd34df..c49ddfdd3 100644 --- a/docs/src/content/docs/reference/experimental.md +++ b/docs/src/content/docs/reference/experimental.md @@ -167,10 +167,11 @@ apm experimental reset verbose-version ## Available flags -| Name | Description | -|-------------------|----------------------------------------------------------------------------------| -| `verbose-version` | Show Python version, platform, and install path in `apm --version`. | -| `copilot-cowork` | Deploy APM skills to Microsoft 365 Copilot Cowork via OneDrive. | +| Name | Description | +|-----------------------|----------------------------------------------------------------------------------| +| `verbose-version` | Show Python version, platform, and install path in `apm --version`. | +| `copilot-cowork` | Deploy APM skills to Microsoft 365 Copilot Cowork via OneDrive. | +| `marketplace-authoring`| Enable marketplace authoring commands (init, build, publish, etc.). | New flags are proposed via [CONTRIBUTING.md](https://github.com/microsoft/apm/blob/main/CONTRIBUTING.md#how-to-add-an-experimental-feature-flag) and graduate to default when stable. See the contributor recipe for the full lifecycle. See also: [Cowork integration](../integrations/copilot-cowork/). diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 393bdbcf4..1eaa36c4f 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -48,7 +48,9 @@ | `apm pack` | Bundle package for distribution | `-o PATH`, `-t TARGET`, `--archive`, `--dry-run`, `--format [apm\|plugin]`, `--force` | | `apm unpack BUNDLE` | Extract a bundle | `-o PATH`, `--skip-verify`, `--force`, `--dry-run` | -## Marketplace +## Marketplace (experimental — authoring only) + +> **Authoring commands gated behind `apm experimental enable marketplace-authoring`**. Consumer commands (add, list, browse, update, remove, validate, search) are always available. | Command | Purpose | Key flags | |---------|---------|-----------| @@ -62,6 +64,20 @@ | `apm install NAME@MKT[#ref]` | Install from marketplace | Optional `#ref` override | | `apm view NAME@MARKETPLACE` | View marketplace plugin info | -- | +## Marketplace authoring (experimental) + +| Command | Purpose | Key flags | +|---------|---------|-----------| +| `apm marketplace init` | Scaffold `marketplace.yml` in CWD | `--force`, `--no-gitignore-check` | +| `apm marketplace build` | Compile `marketplace.yml` to Anthropic-compliant `marketplace.json` | `--dry-run`, `--offline`, `--include-prerelease`, `-v` | +| `apm marketplace outdated` | Report upgradable packages, range-aware | `--offline`, `--include-prerelease`, `-v` | +| `apm marketplace check` | Validate yml and verify refs resolve | `--offline`, `-v` | +| `apm marketplace doctor` | Diagnose git, network, auth, yml readiness | `-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 marketplace package add ` | Add a package entry to `marketplace.yml` | `--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 package entry | `--version`, `--ref` (mutable refs auto-resolved to SHA), `--description`, `--subdir`, `--tag-pattern`, `--tags`, `--include-prerelease` | +| `apm marketplace package remove ` | Remove a package entry from `marketplace.yml` | `--yes` | + ## MCP servers | Command | Purpose | Key flags | 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 49cb62fa2..9dda27901 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -169,6 +169,70 @@ git tag v1.0.0 && git push --tags apm install org/my-package#v1.0.0 ``` +## Marketplace authoring + +A **marketplace** is a curated index of packages (plugins) that consumers +install via `apm install @`. Maintainers author a +`marketplace.yml` source file and compile it to an Anthropic-compliant +`marketplace.json` with `apm marketplace build`. Both files are committed. + +### When to run `apm marketplace init` + +- The user is setting up a new marketplace repository. +- The user wants to convert an ad-hoc list of plugins into a proper index. + +Do NOT run `init` inside an existing package directory; a marketplace +repository is a separate repo whose job is to list plugins, not to be one. + +### marketplace.yml shape + +```yaml +name: my-marketplace +description: Short summary +version: 0.1.0 +owner: + name: acme-org + url: https://github.com/acme-org +build: # APM-only, stripped at compile time + tagPattern: "v{version}" +metadata: # pass-through, copied verbatim to marketplace.json + homepage: https://example.com +packages: + - name: example-package + description: What this package does + source: acme-org/example-package # / + version: "^1.0.0" # semver range OR 'ref:' below + # ref: 3f2a9b1c # explicit SHA/tag/branch; overrides version + # subdir: tools/x # optional subdirectory + # tag_pattern: "{name}-v{version}" # optional per-package override + # include_prerelease: false # optional +``` + +Schema rules: +- `name`, `description`, `version`, `owner.name` are required. +- Each package needs either `version` (a semver range) or `ref` (explicit). +- `ref` takes precedence over `version`. +- Unknown keys raise a schema error -- do not invent fields. + +### Build semantics + +`apm marketplace build` runs `git ls-remote` against each package source, +picks the highest tag satisfying the range (under the applicable +`tagPattern`), and writes `marketplace.json`. The compiler: + +1. Emits `plugins:` verbatim (Anthropic's key name). +2. Copies `metadata:` byte-for-byte. +3. Strips `build:`, per-package `version`, `tag_pattern`, `include_prerelease`. +4. Does not emit `versions[]` -- each plugin carries a single resolved ref. + +Exit codes: `0` success, `1` build error, `2` schema error. + +### Full guide + +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`). + ## Org-wide packages For organization-wide standards, create a single repository with shared diff --git a/pyproject.toml b/pyproject.toml index 873d8504b..b82769ff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "rich-click>=1.7.0", "watchdog>=3.0.0", "GitPython>=3.1.0", + "ruamel.yaml>=0.18.0", ] [project.optional-dependencies] diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index 136cd0ae7..9179e48d2 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -5,6 +5,7 @@ import builtins import os +import sys import tempfile from pathlib import Path @@ -44,6 +45,17 @@ HIGHLIGHT = f"{Fore.MAGENTA}{Style.BRIGHT}" RESET = Style.RESET_ALL + +# ------------------------------------------------------------------- +# TTY detection +# ------------------------------------------------------------------- + + +def _is_interactive(): + """Return True when both stdin and stdout are attached to a TTY.""" + return sys.stdin.isatty() and sys.stdout.isatty() + + # Lazy loading for Rich components to improve startup performance _console = None diff --git a/src/apm_cli/commands/marketplace.py b/src/apm_cli/commands/marketplace.py index 3d50f82a3..1c62c0c61 100644 --- a/src/apm_cli/commands/marketplace.py +++ b/src/apm_cli/commands/marketplace.py @@ -1,25 +1,278 @@ """APM marketplace command group. -Manages plugin marketplace discovery and governance. Follows the same +Manages marketplace discovery and governance. Follows the same Click group pattern as ``mcp.py``. """ import builtins +import json +import os +import subprocess import sys +import traceback +from pathlib import Path import click +import yaml from ..core.command_logger import CommandLogger -from ._helpers import _get_console +from ..marketplace.builder import BuildOptions, BuildReport, MarketplaceBuilder, ResolvedPackage +from ..marketplace.errors import ( + BuildError, + GitLsRemoteError, + HeadNotAllowedError, + MarketplaceNotFoundError, + MarketplaceYmlError, + NoMatchingVersionError, + OfflineMissError, + RefNotFoundError, +) +from ..marketplace.git_stderr import translate_git_stderr +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 +from ..utils.path_security import PathTraversalError, validate_path_segments +from ..utils.console import _rich_info, _rich_warning +from ._helpers import _get_console, _is_interactive + + +# --------------------------------------------------------------------------- +# Custom group for organised --help output +# --------------------------------------------------------------------------- + + +class MarketplaceGroup(click.Group): + """Custom group that organises commands by audience.""" + + _consumer_commands = ["add", "list", "browse", "update", "remove", "validate"] + _authoring_commands = ["init", "build", "check", "outdated", "doctor", "publish", "package"] + + @staticmethod + def _authoring_visible() -> bool: + """Return True when authoring commands should appear in ``--help``.""" + try: + from ..core.experimental import is_enabled + + return is_enabled("marketplace_authoring") + except Exception: # noqa: BLE001 -- fail-open UI visibility check + return True # fail open — show commands if flag check fails + + def format_commands(self, ctx, formatter): + sections = [("Consumer commands", self._consumer_commands)] + if self._authoring_visible(): + sections.append(("Authoring commands", self._authoring_commands)) + + for section_name, cmd_names in sections: + commands = [] + for name in cmd_names: + cmd = self.get_command(ctx, name) + if cmd is None: + continue + help_text = cmd.get_short_help_str(limit=150) + commands.append((name, help_text)) + if commands: + with formatter.section(section_name): + formatter.write_dl(commands) # Restore builtins shadowed by subcommand names list = builtins.list -@click.group(help="Manage plugin marketplaces for discovery and governance") -def marketplace(): - """Register, browse, and search plugin marketplaces.""" - pass +# --------------------------------------------------------------------------- +# Module-private helpers +# --------------------------------------------------------------------------- + + +def _load_yml_or_exit(logger): + """Load ``./marketplace.yml`` from CWD or exit with an appropriate code. + + Returns the parsed ``MarketplaceYml`` on success. + Calls ``sys.exit(1)`` on ``FileNotFoundError`` and + ``sys.exit(2)`` on ``MarketplaceYmlError`` (schema/parse errors). + """ + yml_path = Path.cwd() / "marketplace.yml" + if not yml_path.exists(): + logger.error( + "No marketplace.yml found. Run 'apm marketplace init' to scaffold one.", + symbol="error", + ) + sys.exit(1) + try: + return load_marketplace_yml(yml_path) + except MarketplaceYmlError as exc: + logger.error(f"marketplace.yml schema error: {exc}", symbol="error") + sys.exit(2) + + +def _warn_duplicate_names(logger, yml): + """Emit a warning for each duplicate package name in *yml*.""" + seen: dict[str, int] = {} + for idx, entry in enumerate(yml.packages): + lower = entry.name.lower() + if lower in seen: + logger.warning( + f"Duplicate package name '{entry.name}' " + f"(packages[{seen[lower]}] and packages[{idx}]). " + f"Consumers will see duplicate entries in browse.", + symbol="warning", + ) + else: + seen[lower] = idx + + +def _find_duplicate_names(yml): + """Return a diagnostic string if *yml* contains duplicate package names.""" + seen: dict[str, int] = {} + duplicates: list[str] = [] + for idx, entry in enumerate(yml.packages): + lower = entry.name.lower() + if lower in seen: + duplicates.append( + f"'{entry.name}' (packages[{seen[lower]}] and packages[{idx}])" + ) + else: + seen[lower] = idx + if duplicates: + return f"Duplicate names: {', '.join(duplicates)}" + return "" + +def _require_authoring_flag(): + """Exit with enablement hint if marketplace-authoring flag is disabled.""" + from ..core.experimental import is_enabled + + if not is_enabled("marketplace_authoring"): + _rich_warning( + "Marketplace authoring commands are experimental.", + symbol="warning", + ) + _rich_info( + "Enable with: apm experimental enable marketplace-authoring", + symbol="info", + ) + _rich_info( + "Learn more: apm experimental list", + symbol="info", + ) + _rich_info( + "Docs: https://microsoft.github.io/apm/guides/marketplace-authoring/", + symbol="info", + ) + sys.exit(1) + + +@click.group(cls=MarketplaceGroup, help="Manage marketplaces for discovery and governance") +@click.pass_context +def marketplace(ctx): + """Register, browse, and search marketplaces.""" + + +from .marketplace_plugin import package # noqa: E402 + +marketplace.add_command(package) + + +# --------------------------------------------------------------------------- +# marketplace init +# --------------------------------------------------------------------------- + + +@marketplace.command(help="Scaffold a new marketplace.yml in the current directory") +@click.option("--force", is_flag=True, help="Overwrite existing marketplace.yml") +@click.option( + "--no-gitignore-check", + is_flag=True, + help="Skip the .gitignore staleness check", +) +@click.option("--name", default=None, help="Marketplace name (default: my-marketplace)") +@click.option("--owner", default=None, help="Owner name for the marketplace") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def init(force, no_gitignore_check, name, owner, verbose): + """Create a richly-commented marketplace.yml scaffold.""" + _require_authoring_flag() + from ..marketplace.init_template import render_marketplace_yml_template + + logger = CommandLogger("marketplace-init", verbose=verbose) + yml_path = Path.cwd() / "marketplace.yml" + + # Guard: file already exists + if yml_path.exists() and not force: + logger.error( + "marketplace.yml already exists. Use --force to overwrite.", + symbol="error", + ) + sys.exit(1) + + # Write template + template_text = render_marketplace_yml_template(name=name, owner=owner) + try: + yml_path.write_text(template_text, encoding="utf-8") + except OSError as exc: + logger.error(f"Failed to write marketplace.yml: {exc}", symbol="error") + sys.exit(1) + + logger.success("Created marketplace.yml", symbol="check") + + if verbose: + logger.verbose_detail(f" Path: {yml_path}") + + # .gitignore staleness check + if not no_gitignore_check: + _check_gitignore_for_marketplace_json(logger) + + # Next steps panel + next_steps = [ + "Edit marketplace.yml to add your packages", + "Run 'apm marketplace build' to generate marketplace.json", + "Commit BOTH marketplace.yml and marketplace.json", + ] + + try: + from ..utils.console import _rich_panel + + _rich_panel( + "\n".join(f" {i}. {step}" for i, step in enumerate(next_steps, 1)), + title=" Next Steps", + style="cyan", + ) + except (ImportError, NameError): + logger.progress("Next steps:") + for i, step in enumerate(next_steps, 1): + logger.tree_item(f" {i}. {step}") + + +def _check_gitignore_for_marketplace_json(logger): + """Warn if .gitignore contains a rule that would ignore marketplace.json.""" + gitignore_path = Path.cwd() / ".gitignore" + if not gitignore_path.exists(): + return + + try: + lines = gitignore_path.read_text(encoding="utf-8").splitlines() + except OSError: + return + + patterns = {"marketplace.json", "**/marketplace.json", "/marketplace.json", "*.json"} + for line in lines: + stripped = line.strip() + # Skip blank and commented lines + if not stripped or stripped.startswith("#"): + continue + if stripped in patterns: + logger.warning( + "Your .gitignore ignores marketplace.json. " + "Both marketplace.yml and marketplace.json must be tracked " + "in git. Remove the .gitignore rule.", + symbol="warning", + ) + return # --------------------------------------------------------------------------- @@ -27,7 +280,7 @@ def marketplace(): # --------------------------------------------------------------------------- -@marketplace.command(help="Register a plugin marketplace") +@marketplace.command(help="Register a marketplace") @click.argument("repo", required=True) @click.option("--name", "-n", default=None, help="Display name (defaults to repo name)") @click.option("--branch", "-b", default="main", show_default=True, help="Branch to use") @@ -146,8 +399,10 @@ def add(repo, name, branch, host, verbose): if manifest.description: logger.verbose_detail(f" {manifest.description}") - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Failed to register marketplace: {e}") + if verbose: + logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) @@ -181,7 +436,7 @@ def list_cmd(verbose): f"{len(sources)} marketplace(s) registered:", symbol="info" ) for s in sources: - click.echo(f" {s.name} ({s.owner}/{s.repo})") + logger.tree_item(f" {s.name} ({s.owner}/{s.repo})") return from rich.table import Table @@ -202,12 +457,15 @@ def list_cmd(verbose): console.print() console.print(table) - console.print( - f"\n[dim]Use 'apm marketplace browse ' to see plugins[/dim]" + logger.progress( + "Use 'apm marketplace browse ' to see plugins", + symbol="info", ) - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Failed to list marketplaces: {e}") + if verbose: + logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) @@ -243,9 +501,9 @@ def browse(name, verbose): ) for p in manifest.plugins: desc = f" -- {p.description}" if p.description else "" - click.echo(f" {p.name}{desc}") - click.echo( - f"\n Install: apm install @{name}" + logger.tree_item(f" {p.name}{desc}") + logger.progress( + f"Install: apm install @{name}", symbol="info" ) return @@ -269,12 +527,15 @@ def browse(name, verbose): console.print() console.print(table) - console.print( - f"\n[dim]Install a plugin: apm install @{name}[/dim]" + logger.progress( + f"Install a plugin: apm install @{name}", + symbol="info", ) - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Failed to browse marketplace: {e}") + if verbose: + logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) @@ -322,12 +583,16 @@ def update(name, verbose): logger.tree_item( f" {s.name} ({len(manifest.plugins)} plugins)" ) - except Exception as exc: + except Exception as exc: # noqa: BLE001 -- per-marketplace best-effort logger.warning(f" {s.name}: {exc}") + if verbose: + logger.progress(traceback.format_exc(), symbol="info") logger.success("Marketplace cache refreshed", symbol="check") - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Failed to update marketplace: {e}") + if verbose: + logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) @@ -338,7 +603,7 @@ def update(name, verbose): @marketplace.command(help="Remove a registered marketplace") @click.argument("name", required=True) -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def remove(name, yes, verbose): """Unregister a marketplace.""" @@ -351,6 +616,12 @@ def remove(name, yes, verbose): source = get_marketplace_by_name(name) if not yes: + if not _is_interactive(): + logger.error( + "Use --yes to skip confirmation in non-interactive mode", + symbol="error", + ) + sys.exit(1) confirmed = click.confirm( f"Remove marketplace '{source.name}' ({source.owner}/{source.repo})?", default=False, @@ -363,8 +634,10 @@ def remove(name, yes, verbose): clear_marketplace_cache(name, host=source.host) logger.success(f"Marketplace '{name}' removed", symbol="check") - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Failed to remove marketplace: {e}") + if verbose: + logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) @@ -376,7 +649,7 @@ def remove(name, yes, verbose): @marketplace.command(help="Validate a marketplace manifest") @click.argument("name", required=True) @click.option( - "--check-refs", is_flag=True, help="Verify version refs are reachable (network)" + "--check-refs", is_flag=True, hidden=True, help="Verify version refs are reachable (network)" ) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def validate(name, check_refs, verbose): @@ -421,7 +694,7 @@ def validate(name, check_refs, verbose): warning_count = 0 error_count = 0 click.echo() - click.echo("Validation Results:") + logger.progress("Validation Results:", symbol="info") for r in results: if r.passed and not r.warnings: logger.success( @@ -441,17 +714,1249 @@ def validate(name, check_refs, verbose): warning_count += len(r.warnings) click.echo() - click.echo( + logger.progress( f"Summary: {passed} passed, {warning_count} warnings, " - f"{error_count} errors" + f"{error_count} errors", + symbol="info", ) if error_count > 0: sys.exit(1) - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Failed to validate marketplace: {e}") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# marketplace build +# --------------------------------------------------------------------------- + + +@marketplace.command(help="Build marketplace.json from marketplace.yml") +@click.option("--dry-run", is_flag=True, help="Preview without writing marketplace.json") +@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def build(dry_run, offline, include_prerelease, verbose): + """Resolve packages and compile marketplace.json.""" + _require_authoring_flag() + logger = CommandLogger("marketplace-build", verbose=verbose) + yml_path = Path.cwd() / "marketplace.yml" + + # Load yml (exit 1 on missing, exit 2 on schema error) + _load_yml_or_exit(logger) + + try: + opts = BuildOptions( + dry_run=dry_run, + offline=offline, + include_prerelease=include_prerelease, + ) + builder = MarketplaceBuilder(yml_path, options=opts) + report = builder.build() + except MarketplaceYmlError as exc: + logger.error(f"marketplace.yml schema error: {exc}", symbol="error") + sys.exit(2) + except BuildError as exc: + _render_build_error(logger, exc) + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Build failed: {e}", symbol="error") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + + # Render results table + _render_build_table(logger, report) + + # Surface duplicate-name warnings from the builder + for warn_msg in report.warnings: + logger.warning(warn_msg, symbol="warning") + + if dry_run: + logger.progress( + "Dry run -- marketplace.json not written", symbol="info" + ) + else: + logger.success( + f"Built marketplace.json ({len(report.resolved)} packages)", + symbol="check", + ) + + +def _render_build_error(logger, exc): + """Render a BuildError with actionable hints.""" + if isinstance(exc, GitLsRemoteError): + logger.error(exc.summary_text, symbol="error") + if exc.hint: + logger.progress(f"Hint: {exc.hint}", symbol="info") + elif isinstance(exc, NoMatchingVersionError): + logger.error(str(exc), symbol="error") + logger.progress( + "Check that your version range matches published tags.", + symbol="info", + ) + elif isinstance(exc, RefNotFoundError): + logger.error(str(exc), symbol="error") + logger.progress( + "Verify the ref is spelled correctly and the remote is reachable.", + symbol="info", + ) + elif isinstance(exc, HeadNotAllowedError): + logger.error(str(exc), symbol="error") + elif isinstance(exc, OfflineMissError): + logger.error(str(exc), symbol="error") + logger.progress( + "Run a build online first to populate the cache.", + symbol="info", + ) + else: + logger.error(f"Build failed: {exc}", symbol="error") + + +def _render_build_table(logger, report): + """Render the resolved-packages table (Rich with colorama fallback).""" + console = _get_console() + if not console: + # Colorama fallback + for pkg in report.resolved: + sha_short = pkg.sha[:8] if pkg.sha else "--" + ref_kind = "tag" if not pkg.ref.startswith("refs/heads/") else "branch" + logger.tree_item( + f" [+] {pkg.name} {pkg.ref} {sha_short} ({ref_kind})" + ) + return + + from rich.table import Table + from rich.text import Text + + table = Table( + title="Resolved Packages", + show_header=True, + header_style="bold cyan", + border_style="cyan", + ) + table.add_column("Status", style="green", no_wrap=True, width=6) + table.add_column("Package", style="bold white", no_wrap=True) + table.add_column("Version", style="cyan") + table.add_column("Commit", style="dim") + table.add_column("Ref Kind", style="white") + + for pkg in report.resolved: + sha_short = pkg.sha[:8] if pkg.sha else "--" + # Determine ref kind + ref_kind = "tag" + if pkg.ref and not parse_semver(pkg.ref.lstrip("vV")): + ref_kind = "ref" + table.add_row(Text("[+]"), pkg.name, pkg.ref, sha_short, ref_kind) + + console.print() + console.print(table) + + +# --------------------------------------------------------------------------- +# marketplace outdated +# --------------------------------------------------------------------------- + + +@marketplace.command(help="Show packages with available upgrades") +@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def outdated(offline, include_prerelease, verbose): + """Compare installed versions against latest available tags.""" + _require_authoring_flag() + logger = CommandLogger("marketplace-outdated", verbose=verbose) + + yml = _load_yml_or_exit(logger) + + # Load current marketplace.json for "Current" column + current_versions = _load_current_versions() + + resolver = RefResolver(offline=offline) + try: + rows = [] + upgradable = 0 + up_to_date = 0 + for entry in yml.packages: + # Entries with explicit ref (no range) are skipped + if entry.ref is not None: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec="--", + latest_in_range="--", + latest_overall="--", + status="[i]", + note="Pinned to ref; skipped", + )) + continue + + version_range = entry.version or "" + if not version_range: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec="--", + latest_in_range="--", + latest_overall="--", + status="[i]", + note="No version range", + )) + continue + + try: + refs = resolver.list_remote_refs(entry.source) + except (BuildError, Exception) as exc: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec=version_range, + latest_in_range="--", + latest_overall="--", + status="[x]", + note=str(exc)[:60], + )) + continue + + # Parse tags into semvers + tag_versions = _extract_tag_versions( + refs, entry, yml, include_prerelease + ) + + if not tag_versions: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec=version_range, + latest_in_range="--", + latest_overall="--", + status="[!]", + note="No matching tags found", + )) + continue + + # Find highest in-range and highest overall + in_range = [ + (sv, tag) for sv, tag in tag_versions + if satisfies_range(sv, version_range) + ] + latest_overall_sv, latest_overall_tag = max( + tag_versions, key=lambda x: x[0] + ) + latest_in_range_tag = "--" + if in_range: + _, latest_in_range_tag = max(in_range, key=lambda x: x[0]) + + current = current_versions.get(entry.name, "--") + + # Determine status + if current == latest_in_range_tag: + status = "[+]" + up_to_date += 1 + elif latest_in_range_tag != "--" and current != latest_in_range_tag: + status = "[!]" + upgradable += 1 + else: + status = "[!]" + upgradable += 1 + + # Check if major upgrade available outside range + if latest_overall_tag != latest_in_range_tag: + status = "[*]" + + rows.append(_OutdatedRow( + name=entry.name, + current=current, + range_spec=version_range, + latest_in_range=latest_in_range_tag, + latest_overall=latest_overall_tag, + status=status, + note="", + )) + + _render_outdated_table(logger, rows) + + if upgradable > 0: + logger.progress( + f"{upgradable} package(s) can be updated", + symbol="info", + ) + else: + logger.progress( + "All packages are up to date", + symbol="info", + ) + + if verbose: + logger.verbose_detail(f" {upgradable} upgradable entries") + + if upgradable > 0: + sys.exit(1) + sys.exit(0) + + except SystemExit: + raise + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Failed to check outdated packages: {e}", symbol="error") + logger.verbose_detail(traceback.format_exc()) sys.exit(1) + finally: + resolver.close() + + +class _OutdatedRow: + """Simple container for outdated table row data.""" + + __slots__ = ( + "name", "current", "range_spec", "latest_in_range", + "latest_overall", "status", "note", + ) + + def __init__(self, name, current, range_spec, latest_in_range, + latest_overall, status, note): + self.name = name + self.current = current + self.range_spec = range_spec + self.latest_in_range = latest_in_range + self.latest_overall = latest_overall + self.status = status + self.note = note + + +def _load_current_versions(): + """Load current ref versions from marketplace.json if present.""" + mkt_path = Path.cwd() / "marketplace.json" + if not mkt_path.exists(): + return {} + try: + data = json.loads(mkt_path.read_text(encoding="utf-8")) + result = {} + for plugin in data.get("plugins", []): + name = plugin.get("name", "") + src = plugin.get("source", {}) + if isinstance(src, dict): + result[name] = src.get("ref", "--") + return result + except (json.JSONDecodeError, OSError): + return {} + + +def _extract_tag_versions(refs, entry, yml, include_prerelease): + """Extract (SemVer, tag_name) pairs from remote refs for a package entry.""" + from ..marketplace.tag_pattern import build_tag_regex + + pattern = entry.tag_pattern or yml.build.tag_pattern + tag_rx = build_tag_regex(pattern) + results = [] + for remote_ref in refs: + if not remote_ref.name.startswith("refs/tags/"): + continue + tag_name = remote_ref.name[len("refs/tags/"):] + m = tag_rx.match(tag_name) + if not m: + continue + version_str = m.group("version") + sv = parse_semver(version_str) + if sv is None: + continue + if sv.is_prerelease and not (include_prerelease or entry.include_prerelease): + continue + results.append((sv, tag_name)) + return results + + +def _render_outdated_table(logger, rows): + """Render the outdated-packages table.""" + console = _get_console() + if not console: + for row in rows: + note = f" ({row.note})" if row.note else "" + logger.tree_item( + f" {row.status} {row.name} current={row.current} " + f"latest-in-range={row.latest_in_range} " + f"latest={row.latest_overall}{note}" + ) + return + + from rich.table import Table + from rich.text import Text + + table = Table( + title="Package Version Status", + show_header=True, + header_style="bold cyan", + border_style="cyan", + ) + table.add_column("Status", style="green", no_wrap=True, width=6) + table.add_column("Package", style="bold white", no_wrap=True) + table.add_column("Current", style="white") + table.add_column("Range", style="dim") + table.add_column("Latest in Range", style="cyan") + table.add_column("Latest Overall", style="yellow") + + for row in rows: + note = "" + if row.note: + note = f" ({row.note})" + table.add_row( + Text(row.status), + row.name, + row.current, + row.range_spec, + row.latest_in_range + note, + row.latest_overall, + ) + + console.print() + console.print(table) + + +# --------------------------------------------------------------------------- +# marketplace check +# --------------------------------------------------------------------------- + + +@marketplace.command(help="Validate marketplace.yml entries are resolvable") +@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def check(offline, verbose): + """Validate marketplace.yml and check each entry is resolvable.""" + _require_authoring_flag() + logger = CommandLogger("marketplace-check", verbose=verbose) + + yml = _load_yml_or_exit(logger) + + # Defence-in-depth: flag duplicate package names (yml_schema + # also rejects them, but an extra check keeps diagnostics visible). + _warn_duplicate_names(logger, yml) + + if offline: + logger.progress( + "Offline mode -- only schema and cached-ref checks", + symbol="info", + ) + + resolver = RefResolver(offline=offline) + results = [] + failure_count = 0 + + try: + for entry in yml.packages: + try: + # Attempt to resolve each entry + refs = resolver.list_remote_refs(entry.source) + + # Check version/ref resolution + ref_ok = False + if entry.ref is not None: + # Check the explicit ref exists + for r in refs: + tag_name = r.name + if tag_name.startswith("refs/tags/"): + tag_name = tag_name[len("refs/tags/"):] + elif tag_name.startswith("refs/heads/"): + tag_name = tag_name[len("refs/heads/"):] + if tag_name == entry.ref or r.name == entry.ref: + ref_ok = True + break + if not ref_ok: + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=False, ref_ok=False, + error=f"Ref '{entry.ref}' not found", + )) + failure_count += 1 + continue + else: + # Version range -- check at least one tag satisfies + tag_versions = _extract_tag_versions( + refs, entry, yml, False + ) + version_range = entry.version or "" + matching = [ + (sv, tag) for sv, tag in tag_versions + if satisfies_range(sv, version_range) + ] + if matching: + ref_ok = True + else: + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=len(tag_versions) > 0, + ref_ok=False, + error=f"No tag matching '{version_range}'", + )) + failure_count += 1 + continue + + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=True, ref_ok=True, error="", + )) + + except OfflineMissError: + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error="No cached refs (offline)", + )) + failure_count += 1 + except GitLsRemoteError as exc: + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error=exc.summary_text[:60], + )) + failure_count += 1 + except Exception as exc: # noqa: BLE001 -- per-entry diagnostic catch-all + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error=str(exc)[:60], + )) + failure_count += 1 + logger.verbose_detail(traceback.format_exc()) + + _render_check_table(logger, results) + + total = len(results) + if failure_count > 0: + logger.error( + f"{failure_count} entries have issues", symbol="error" + ) + sys.exit(1) + else: + logger.success( + f"All {total} entries OK", symbol="check" + ) + + finally: + resolver.close() + + +class _CheckResult: + """Container for per-entry check results.""" + + __slots__ = ("name", "reachable", "version_found", "ref_ok", "error") + + def __init__(self, name, reachable, version_found, ref_ok, error): + self.name = name + self.reachable = reachable + self.version_found = version_found + self.ref_ok = ref_ok + self.error = error + + +def _render_check_table(logger, results): + """Render the check-results table.""" + console = _get_console() + if not console: + for r in results: + icon = "[+]" if r.ref_ok else "[x]" + detail = r.error if r.error else "OK" + logger.tree_item(f" {icon} {r.name}: {detail}") + return + + from rich.table import Table + from rich.text import Text + + table = Table( + title="Entry Health Check", + show_header=True, + header_style="bold cyan", + border_style="cyan", + ) + table.add_column("Status", no_wrap=True, width=6) + table.add_column("Package", style="bold white", no_wrap=True) + table.add_column("Reachable", style="white", justify="center") + table.add_column("Version Found", style="white", justify="center") + table.add_column("Ref OK", style="white", justify="center") + table.add_column("Detail", style="dim") + + for r in results: + reach = "[+]" if r.reachable else "[x]" + ver = "[+]" if r.version_found else "[x]" + ref = "[+]" if r.ref_ok else "[x]" + detail = r.error if r.error else "OK" + table.add_row( + Text("[+]" if r.ref_ok else "[x]"), + r.name, + Text(reach), + Text(ver), + Text(ref), + detail, + ) + + console.print() + console.print(table) + + +# --------------------------------------------------------------------------- +# marketplace doctor +# --------------------------------------------------------------------------- + + +@marketplace.command(help="Run environment diagnostics for marketplace builds") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def doctor(verbose): + """Check git, network, auth, and marketplace.yml readiness.""" + _require_authoring_flag() + logger = CommandLogger("marketplace-doctor", verbose=verbose) + checks = [] + + # Check 1: git on PATH + git_ok = False + git_detail = "" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + git_ok = True + git_detail = result.stdout.strip() + else: + git_detail = "git returned non-zero exit code" + except FileNotFoundError: + git_detail = "git not found on PATH" + except subprocess.TimeoutExpired: + git_detail = "git --version timed out" + except (subprocess.SubprocessError, OSError) as exc: + git_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="git", + passed=git_ok, + detail=git_detail, + )) + + # Check 2: network reachability + net_ok = False + net_detail = "" + try: + result = subprocess.run( + ["git", "ls-remote", "https://github.com/git/git.git", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + net_ok = True + net_detail = "github.com reachable" + else: + translated = translate_git_stderr( + result.stderr, + exit_code=result.returncode, + operation="ls-remote", + remote="github.com", + ) + net_detail = translated.hint[:80] + except subprocess.TimeoutExpired: + net_detail = "Network check timed out (5s)" + except FileNotFoundError: + net_detail = "git not found; cannot test network" + except (subprocess.SubprocessError, OSError) as exc: + net_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="network", + passed=net_ok, + detail=net_detail, + )) + + # Check 3: auth tokens (delegate to AuthResolver for full coverage) + try: + from ..core.auth import AuthResolver + resolver = AuthResolver() + # Try to get a token for github.com as a representative check + token = resolver.resolve("github.com").token + has_token = bool(token) + except Exception: # noqa: BLE001 -- best-effort auth probe + has_token = False + auth_detail = "Token detected" if has_token else "No token; unauthenticated rate limits apply" + checks.append(_DoctorCheck( + name="auth", + passed=True, # informational; never fails + detail=auth_detail, + informational=True, + )) + + # 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.yml presence + parsability + yml_path = Path.cwd() / "marketplace.yml" + yml_found = yml_path.exists() + yml_detail = "" + yml_parsed = False + yml_obj = None + if yml_found: + try: + yml_obj = load_marketplace_yml(yml_path) + yml_parsed = True + yml_detail = "marketplace.yml found and valid" + except MarketplaceYmlError as exc: + yml_detail = f"marketplace.yml has errors: {str(exc)[:60]}" + else: + yml_detail = "No marketplace.yml in current directory" + + checks.append(_DoctorCheck( + name="marketplace.yml", + passed=yml_parsed if yml_found else True, # informational if absent + detail=yml_detail, + informational=True, + )) + + # Check 6: duplicate package names (defence-in-depth) + if yml_obj is not None: + dup_detail = _find_duplicate_names(yml_obj) + if dup_detail: + checks.append(_DoctorCheck( + name="duplicate names", + passed=False, + detail=dup_detail, + informational=True, + )) + else: + checks.append(_DoctorCheck( + name="duplicate names", + passed=True, + detail="No duplicate package names", + informational=True, + )) + + _render_doctor_table(logger, checks) + + # Exit: 0 if checks 1-2 pass; checks 3-6 are informational + critical_checks = [c for c in checks if not c.informational] + if any(not c.passed for c in critical_checks): + sys.exit(1) + + +class _DoctorCheck: + """Container for a single doctor check result.""" + + __slots__ = ("name", "passed", "detail", "informational") + + def __init__(self, name, passed, detail, informational=False): + self.name = name + self.passed = passed + self.detail = detail + self.informational = informational + + +def _render_doctor_table(logger, checks): + """Render the doctor results table.""" + console = _get_console() + if not console: + for c in checks: + if c.informational: + icon = "[i]" + elif c.passed: + icon = "[+]" + else: + icon = "[x]" + logger.tree_item(f" {icon} {c.name}: {c.detail}") + return + + from rich.table import Table + from rich.text import Text + + table = Table( + title="Environment Diagnostics", + show_header=True, + header_style="bold cyan", + border_style="cyan", + ) + table.add_column("Check", style="bold white", no_wrap=True) + table.add_column("Status", no_wrap=True, width=6) + table.add_column("Detail", style="white") + + for c in checks: + if c.informational: + icon = "[i]" + elif c.passed: + icon = "[+]" + else: + icon = "[x]" + table.add_row(c.name, Text(icon), c.detail) + + console.print() + console.print(table) + + +# --------------------------------------------------------------------------- +# marketplace publish +# --------------------------------------------------------------------------- + + +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 + + +@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.""" + _require_authoring_flag() + logger = CommandLogger("marketplace-publish", verbose=verbose) + + # ------------------------------------------------------------------ + # 1. Pre-flight checks + # ------------------------------------------------------------------ + + # 1a. Load marketplace.yml + yml = _load_yml_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 marketplace build' 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: + 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: # noqa: BLE001 -- best-effort Rich rendering fallback + 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) + + +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, " + f"{failed} failed{suffix}", + symbol="warning", + ) # --------------------------------------------------------------------------- @@ -463,7 +1968,7 @@ def validate(name, check_refs, verbose): name="search", help="Search plugins in a marketplace (QUERY@MARKETPLACE)", ) -@click.argument("expression", required=True) +@click.argument("expression", required=True, metavar="QUERY@MARKETPLACE") @click.option("--limit", default=20, show_default=True, help="Max results to show") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def search(expression, limit, verbose): @@ -493,7 +1998,7 @@ def search(expression, limit, verbose): try: source = get_marketplace_by_name(marketplace_name) - except Exception: + except MarketplaceNotFoundError: logger.error( f"Marketplace '{marketplace_name}' is not registered. " "Use 'apm marketplace list' to see registered marketplaces." @@ -518,9 +2023,10 @@ def search(expression, limit, verbose): logger.success(f"Found {len(results)} plugin(s):", symbol="check") for p in results: desc = f" -- {p.description}" if p.description else "" - click.echo(f" {p.name}@{marketplace_name}{desc}") - click.echo( - f"\n Install: apm install @{marketplace_name}" + logger.tree_item(f" {p.name}@{marketplace_name}{desc}") + logger.progress( + f"Install: apm install @{marketplace_name}", + symbol="info", ) return @@ -544,13 +2050,15 @@ def search(expression, limit, verbose): console.print() console.print(table) - console.print( - f"\n[dim]Install: apm install @{marketplace_name}[/dim]" + logger.progress( + f"Install: apm install @{marketplace_name}", + symbol="info", ) except SystemExit: raise - except Exception as e: + except Exception as e: # noqa: BLE001 -- top-level command catch-all logger.error(f"Search failed: {e}") + logger.verbose_detail(traceback.format_exc()) sys.exit(1) diff --git a/src/apm_cli/commands/marketplace_plugin.py b/src/apm_cli/commands/marketplace_plugin.py new file mode 100644 index 000000000..26eafc8e4 --- /dev/null +++ b/src/apm_cli/commands/marketplace_plugin.py @@ -0,0 +1,395 @@ +"""``apm marketplace package {add,set,remove}`` subgroup. + +Lets maintainers programmatically manage package entries in +``marketplace.yml`` instead of hand-editing YAML. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import click + +from ..core.command_logger import CommandLogger +from ..marketplace.errors import ( + GitLsRemoteError, + MarketplaceYmlError, + OfflineMissError, +) +from ._helpers import _is_interactive + + +# ------------------------------------------------------------------- +# Constants +# ------------------------------------------------------------------- + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +# ------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------- + + +def _yml_path() -> Path: + """Return the canonical ``marketplace.yml`` path in CWD.""" + return Path.cwd() / "marketplace.yml" + + +def _ensure_yml_exists(logger: CommandLogger) -> Path: + """Return the yml path or exit with guidance if it does not exist.""" + path = _yml_path() + if not path.exists(): + logger.error( + "No marketplace.yml found. " + "Run 'apm marketplace init' to scaffold one.", + symbol="error", + ) + sys.exit(1) + return path + + +def _parse_tags(raw: str | None) -> list[str] | None: + """Split a comma-separated tag string into a list, or return None.""" + if raw is None: + return None + parts = [t.strip() for t in raw.split(",") if t.strip()] + return parts if parts else None + + +def _verify_source(logger: CommandLogger, source: str) -> None: + """Run ``git ls-remote`` against *source* to verify reachability.""" + from ..marketplace.ref_resolver import RefResolver + + resolver = RefResolver() + try: + resolver.list_remote_refs(source) + except GitLsRemoteError as exc: + logger.error( + f"Source '{source}' is not reachable: {exc}", + symbol="error", + ) + sys.exit(2) + except OfflineMissError: + logger.warning( + f"Cannot verify source '{source}' (offline / no cache).", + symbol="warning", + ) + + +def _resolve_ref( + logger: CommandLogger, + source: str, + ref: str | None, + version: str | None, + no_verify: bool, +) -> str | None: + """Resolve *ref* to a concrete SHA when it is mutable. + + Returns the (possibly resolved) ref string, or ``None`` when + *version* is set (version-based pinning, no ref needed). + """ + from ..marketplace.ref_resolver import RefResolver + + # Version-based — no ref resolution needed. + if version is not None: + return None + + # Already a concrete SHA — store as-is. + if ref is not None and _SHA_RE.match(ref): + return ref + + # HEAD (explicit or implicit) requires network access. + is_head = ref is None or ref.upper() == "HEAD" + if is_head: + if no_verify: + logger.error( + "Cannot resolve HEAD ref without network access. " + "Provide an explicit --ref SHA.", + symbol="error", + ) + sys.exit(2) + if ref is not None: + logger.warning( + "'HEAD' is a mutable ref. Resolving to current SHA for safety.", + symbol="warning", + ) + resolver = RefResolver() + try: + sha = resolver.resolve_ref_sha(source, "HEAD") + except GitLsRemoteError as exc: + logger.error( + f"Failed to resolve HEAD for '{source}': {exc}", + symbol="error", + ) + sys.exit(2) + logger.progress( + f"Resolved HEAD to {sha[:12]}", + symbol="info", + ) + return sha + + # Non-HEAD, non-SHA ref — check whether it is a branch name. + resolver = RefResolver() + try: + remote_refs = resolver.list_remote_refs(source) + except (GitLsRemoteError, OfflineMissError): + # Cannot verify — store as-is but warn the user. + logger.warning( + f"Could not verify ref '{ref}' for '{source}' (network unavailable). " + "Storing unresolved -- run with network access to pin a concrete SHA.", + symbol="warning", + ) + return ref + + for remote_ref in remote_refs: + if remote_ref.name == f"refs/heads/{ref}": + if no_verify: + logger.error( + "Cannot resolve branch ref without network access. " + "Provide an explicit --ref SHA.", + symbol="error", + ) + sys.exit(2) + logger.warning( + f"'{ref}' is a branch (mutable ref). " + "Resolving to current SHA for safety.", + symbol="warning", + ) + logger.progress( + f"Resolved {ref} to {remote_ref.sha[:12]}", + symbol="info", + ) + return remote_ref.sha + + # Not a branch — tag or unknown ref; store as-is. + return ref + + +# ------------------------------------------------------------------- +# Click group +# ------------------------------------------------------------------- + + +@click.group(help="Manage packages in marketplace.yml (add, set, remove)") +def package(): + """Add, update, or remove packages in marketplace.yml.""" + from ..commands.marketplace import _require_authoring_flag + + _require_authoring_flag() + + +# ------------------------------------------------------------------- +# package add +# ------------------------------------------------------------------- + + +@package.command(help="Add a package to marketplace.yml") +@click.argument("source") +@click.option("--name", default=None, help="Package name (default: repo name)") +@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") +@click.option( + "--ref", + default=None, + help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +) +@click.option("-s", "--subdir", default=None, help="Subdirectory inside source repo") +@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") +@click.option("--tags", default=None, help="Comma-separated tags") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--no-verify", is_flag=True, help="Skip remote reachability check") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def add( + source, + name, + version, + ref, + subdir, + tag_pattern, + tags, + include_prerelease, + no_verify, + verbose, +): + """Add a package entry to marketplace.yml.""" + from ..marketplace.yml_editor import add_plugin_entry + + logger = CommandLogger("marketplace-package-add", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # --version and --ref are mutually exclusive. + if version and ref: + raise click.UsageError( + "--version and --ref are mutually exclusive. " + "Use --version for semver ranges or --ref for git refs." + ) + + parsed_tags = _parse_tags(tags) + + # Verify source reachability unless skipped. + if not no_verify: + _verify_source(logger, source) + + # Resolve mutable refs to concrete SHAs. + ref = _resolve_ref(logger, source, ref, version, no_verify) + + try: + resolved_name = add_plugin_entry( + yml, + source=source, + name=name, + version=version, + ref=ref, + subdir=subdir, + tag_pattern=tag_pattern, + tags=parsed_tags, + include_prerelease=include_prerelease, + ) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success( + f"Added package '{resolved_name}' from {source}", + symbol="check", + ) + + +# ------------------------------------------------------------------- +# package set +# ------------------------------------------------------------------- + + +@package.command("set", help="Update a package entry in marketplace.yml") +@click.argument("name") +@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") +@click.option( + "--ref", + default=None, + help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +) +@click.option("--subdir", default=None, help="Subdirectory inside source repo") +@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") +@click.option("--tags", default=None, help="Comma-separated tags") +@click.option( + "--include-prerelease", + is_flag=True, + default=None, + help="Include prerelease versions", +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def set_cmd( + name, + version, + ref, + subdir, + tag_pattern, + tags, + include_prerelease, + verbose, +): + """Update fields on an existing package entry.""" + from ..marketplace.yml_editor import update_plugin_entry + + logger = CommandLogger("marketplace-package-set", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # --version and --ref are mutually exclusive. + if version and ref: + raise click.UsageError( + "--version and --ref are mutually exclusive. " + "Use --version for semver ranges or --ref for git refs." + ) + + # Resolve mutable refs to concrete SHAs. + if ref is not None and not _SHA_RE.match(ref): + from ..marketplace.yml_schema import load_marketplace_yml + + yml_data = load_marketplace_yml(yml) + source = None + for pkg in yml_data.packages: + if pkg.name.lower() == name.lower(): + source = pkg.source + break + if source is None: + logger.error(f"Package '{name}' not found", symbol="error") + sys.exit(2) + ref = _resolve_ref(logger, source, ref, version, no_verify=False) + + parsed_tags = _parse_tags(tags) + + fields = {} + if version is not None: + fields["version"] = version + if ref is not None: + fields["ref"] = ref + if subdir is not None: + fields["subdir"] = subdir + if tag_pattern is not None: + fields["tag_pattern"] = tag_pattern + if parsed_tags is not None: + fields["tags"] = parsed_tags + if include_prerelease is not None: + fields["include_prerelease"] = include_prerelease + + if not fields: + logger.error( + "No fields specified. Pass at least one option " + "(e.g. --version, --ref, --subdir).", + symbol="error", + ) + sys.exit(1) + + try: + update_plugin_entry(yml, name, **fields) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success(f"Updated package '{name}'", symbol="check") + + +# ------------------------------------------------------------------- +# package remove +# ------------------------------------------------------------------- + + +@package.command(help="Remove a package from marketplace.yml") +@click.argument("name") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def remove(name, yes, verbose): + """Remove a package entry from marketplace.yml.""" + from ..marketplace.yml_editor import remove_plugin_entry + + logger = CommandLogger("marketplace-package-remove", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # Confirmation gate. + if not yes: + if not _is_interactive(): + logger.error( + "Use --yes to skip confirmation in non-interactive mode", + symbol="error", + ) + sys.exit(1) + try: + click.confirm( + f"Remove package '{name}' from marketplace.yml?", + abort=True, + ) + except click.Abort: + logger.progress("Cancelled.", symbol="info") + return + + try: + remove_plugin_entry(yml, name) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success(f"Removed package '{name}'", symbol="check") diff --git a/src/apm_cli/core/experimental.py b/src/apm_cli/core/experimental.py index b8e9faa83..1f97d3132 100644 --- a/src/apm_cli/core/experimental.py +++ b/src/apm_cli/core/experimental.py @@ -61,7 +61,7 @@ class ExperimentalFlag: default=False, hint="Run 'apm --version' to see the new output.", ), - "copilot_cowork": ExperimentalFlag( +"copilot_cowork": ExperimentalFlag( name="copilot_cowork", description="Enable Microsoft 365 Copilot Cowork skills deployment via OneDrive.", default=False, @@ -70,6 +70,12 @@ class ExperimentalFlag: "See https://microsoft.github.io/apm/integrations/copilot-cowork/" ), ), + "marketplace_authoring": ExperimentalFlag( + name="marketplace_authoring", + description="Enable marketplace authoring commands (init, build, publish, etc.).", + default=False, + hint="Run 'apm marketplace --help' to see available commands.", + ), } diff --git a/src/apm_cli/marketplace/__init__.py b/src/apm_cli/marketplace/__init__.py index e7b28c1ae..91ab890ea 100644 --- a/src/apm_cli/marketplace/__init__.py +++ b/src/apm_cli/marketplace/__init__.py @@ -1,10 +1,17 @@ """Marketplace integration for plugin discovery and governance.""" from .errors import ( + BuildError, + GitLsRemoteError, + HeadNotAllowedError, MarketplaceError, MarketplaceFetchError, MarketplaceNotFoundError, + MarketplaceYmlError, + NoMatchingVersionError, + OfflineMissError, PluginNotFoundError, + RefNotFoundError, ) from .models import ( MarketplaceManifest, @@ -13,16 +20,71 @@ parse_marketplace_json, ) from .resolver import parse_marketplace_ref, resolve_marketplace_plugin +from .yml_schema import ( + MarketplaceBuild, + MarketplaceOwner, + MarketplaceYml, + PackageEntry, + load_marketplace_yml, +) +from .builder import ( + BuildOptions, + BuildReport, + MarketplaceBuilder, + ResolvedPackage, +) +from .publisher import ( + ConsumerTarget, + MarketplacePublisher, + PublishOutcome, + PublishPlan, + TargetResult, +) +from .pr_integration import PrIntegrator, PrResult, PrState +from .ref_resolver import RefResolver, RemoteRef +from .semver import SemVer, parse_semver, satisfies_range +from .tag_pattern import build_tag_regex, render_tag __all__ = [ "MarketplaceError", "MarketplaceFetchError", "MarketplaceNotFoundError", + "MarketplaceYmlError", "PluginNotFoundError", + "BuildError", + "GitLsRemoteError", + "HeadNotAllowedError", + "NoMatchingVersionError", + "OfflineMissError", + "RefNotFoundError", "MarketplaceManifest", "MarketplacePlugin", "MarketplaceSource", "parse_marketplace_json", "parse_marketplace_ref", "resolve_marketplace_plugin", + "MarketplaceBuild", + "MarketplaceOwner", + "MarketplaceYml", + "PackageEntry", + "load_marketplace_yml", + "BuildOptions", + "BuildReport", + "MarketplaceBuilder", + "ResolvedPackage", + "ConsumerTarget", + "MarketplacePublisher", + "PublishOutcome", + "PublishPlan", + "TargetResult", + "PrIntegrator", + "PrResult", + "PrState", + "RefResolver", + "RemoteRef", + "SemVer", + "parse_semver", + "satisfies_range", + "build_tag_regex", + "render_tag", ] diff --git a/src/apm_cli/marketplace/_git_utils.py b/src/apm_cli/marketplace/_git_utils.py new file mode 100644 index 000000000..c3e950896 --- /dev/null +++ b/src/apm_cli/marketplace/_git_utils.py @@ -0,0 +1,19 @@ +"""Shared git-related utilities for marketplace modules.""" + +from __future__ import annotations + +import re + +__all__ = ["redact_token"] + +# Redact auth tokens from git URLs in error messages and logs. +# Covers: https://TOKEN@host, http://TOKEN@host, and ?token=VALUE query params. +_TOKEN_RE = re.compile(r"https?://[^@\s]*@|([?&])token=[^\s&]*") + + +def redact_token(text: str) -> str: + """Replace auth tokens in *text* with redacted placeholders.""" + return _TOKEN_RE.sub( + lambda m: "https://***@" if "@" in m.group() else f"{m.group(1)}token=***", + text, + ) diff --git a/src/apm_cli/marketplace/_io.py b/src/apm_cli/marketplace/_io.py new file mode 100644 index 000000000..13e424fbe --- /dev/null +++ b/src/apm_cli/marketplace/_io.py @@ -0,0 +1,30 @@ +"""Shared I/O helpers for marketplace modules.""" + +from __future__ import annotations + +import os +from pathlib import Path + +__all__ = ["atomic_write"] + + +def atomic_write(path: Path, content: str) -> None: + """Write *content* to *path* atomically via tmp + fsync + rename. + + The caller sees either the complete new content or the previous + content -- never a partial write. + """ + tmp_path = path.with_suffix(path.suffix + ".tmp") + try: + with open(tmp_path, "w", encoding="utf-8", newline="") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(str(tmp_path), str(path)) + except BaseException: + # Clean up tmp file on failure. + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + raise diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py new file mode 100644 index 000000000..44a8f9fcf --- /dev/null +++ b/src/apm_cli/marketplace/builder.py @@ -0,0 +1,730 @@ +"""MarketplaceBuilder -- load, resolve, compose, and write marketplace.json. + +This module implements the full build pipeline: + +1. **Load** -- parse ``marketplace.yml`` via ``yml_schema.load_marketplace_yml``. +2. **Resolve** -- for every package entry, call ``git ls-remote`` (via + ``RefResolver``) and determine the concrete tag + SHA. +3. **Compose** -- produce an Anthropic-compliant ``marketplace.json`` dict + with all APM-only fields stripped. +4. **Write** -- atomically write the JSON to disk (or skip on dry-run) + and produce a ``BuildReport`` with diff statistics. + +Hard rule: the output ``marketplace.json`` conforms byte-for-byte to +Anthropic's schema. No APM-specific keys, no extensions, no renamed +fields. ``packages`` in yml becomes ``plugins`` in json. +""" + +from __future__ import annotations + +import json +import logging +import re +import urllib.error +import urllib.request +from collections import OrderedDict +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +from .errors import ( + BuildError, + HeadNotAllowedError, + NoMatchingVersionError, + OfflineMissError, + RefNotFoundError, +) +from ._io import atomic_write +from .ref_resolver import RefResolver, RemoteRef +from .semver import SemVer, parse_semver, satisfies_range +from .tag_pattern import build_tag_regex, render_tag +from ..utils.path_security import ensure_path_within +from .yml_schema import MarketplaceYml, PackageEntry, load_marketplace_yml + +logger = logging.getLogger(__name__) + +__all__ = [ + "ResolvedPackage", + "ResolveResult", + "BuildReport", + "BuildOptions", + "MarketplaceBuilder", +] + +# --------------------------------------------------------------------------- +# Public dataclasses +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ResolvedPackage: + """A package entry after ref resolution.""" + + name: str + source_repo: str # "owner/repo" only + subdir: Optional[str] # APM-only (used to compose the output ``source`` object) + ref: str # resolved tag name, e.g. "v1.2.0" + sha: str # 40-char git SHA + requested_version: Optional[str] # original APM-only range (for diagnostics) + tags: Tuple[str, ...] + is_prerelease: bool # True if the resolved ref was a prerelease semver + + +@dataclass(frozen=True) +class ResolveResult: + """Result of resolving package refs in a marketplace build.""" + + entries: Tuple[ResolvedPackage, ...] + errors: Tuple[Tuple[str, str], ...] # (package name, error message) pairs + + @property + def ok(self) -> bool: + """True when every package resolved without error.""" + return len(self.errors) == 0 + + +@dataclass(frozen=True) +class BuildReport: + """Summary of a build run.""" + + resolved: Tuple[ResolvedPackage, ...] + errors: Tuple[Tuple[str, str], ...] # (package name, error message) pairs + warnings: Tuple[str, ...] # non-fatal diagnostic messages + unchanged_count: int + added_count: int + updated_count: int + removed_count: int + output_path: Path + dry_run: bool + + +@dataclass +class BuildOptions: + """Configuration knobs for MarketplaceBuilder.""" + + concurrency: int = 8 + timeout_seconds: float = 10.0 + include_prerelease: bool = False + allow_head: bool = False + continue_on_error: bool = False + offline: bool = False + output_override: Optional[Path] = None + dry_run: bool = False + + +# --------------------------------------------------------------------------- +# Builder +# --------------------------------------------------------------------------- + +# 40-char hex SHA pattern +_SHA40_RE = re.compile(r"^[0-9a-f]{40}$") + + +class MarketplaceBuilder: + """Load marketplace.yml, resolve refs, compose and write marketplace.json. + + Parameters + ---------- + marketplace_yml_path: + Path to the ``marketplace.yml`` file. + options: + Build options. Defaults to ``BuildOptions()`` if not provided. + auth_resolver: + Optional ``AuthResolver`` for authenticating requests to private + GitHub repositories. When ``None`` (default) a fresh resolver is + created lazily the first time a token is needed. + """ + + def __init__( + self, + marketplace_yml_path: Path, + options: Optional[BuildOptions] = None, + auth_resolver: Optional[object] = None, + ) -> None: + self._yml_path = marketplace_yml_path + self._options = options or BuildOptions() + self._yml: Optional[MarketplaceYml] = None + self._resolver: Optional[RefResolver] = None + self._auth_resolver = auth_resolver + # Resolved once per build, used by worker threads (read-only). + self._github_token: Optional[str] = None + + # -- lazy loaders ------------------------------------------------------- + + def _load_yml(self) -> MarketplaceYml: + if self._yml is None: + self._yml = load_marketplace_yml(self._yml_path) + return self._yml + + def _get_resolver(self) -> RefResolver: + if self._resolver is None: + self._resolver = RefResolver( + timeout_seconds=self._options.timeout_seconds, + offline=self._options.offline, + ) + return self._resolver + + # -- output path -------------------------------------------------------- + + def _output_path(self) -> Path: + if self._options.output_override is not None: + return self._options.output_override + yml = self._load_yml() + output_path = self._yml_path.parent / yml.output + # Containment guard -- reject output paths that escape the project root. + project_root = self._yml_path.parent + ensure_path_within(output_path, project_root) + return output_path + + # -- single-entry resolution -------------------------------------------- + + def _resolve_entry(self, entry: PackageEntry) -> ResolvedPackage: + """Resolve a single package entry to a concrete tag + SHA.""" + yml = self._load_yml() + resolver = self._get_resolver() + owner_repo = entry.source + + if entry.ref is not None: + return self._resolve_explicit_ref(entry, resolver, owner_repo) + # version range resolution + return self._resolve_version_range(entry, resolver, owner_repo, yml) + + def _resolve_explicit_ref( + self, + entry: PackageEntry, + resolver: RefResolver, + owner_repo: str, + ) -> ResolvedPackage: + """Resolve an entry with an explicit ``ref:`` field.""" + ref_text = entry.ref + assert ref_text is not None + + # If it looks like a 40-char SHA, accept it directly + if _SHA40_RE.match(ref_text): + sv = parse_semver(ref_text.lstrip("vV")) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=ref_text, + sha=ref_text, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=sv.is_prerelease if sv else False, + ) + + refs = resolver.list_remote_refs(owner_repo) + + # Try as tag first (only check tag refs) + for remote_ref in refs: + if not remote_ref.name.startswith("refs/tags/"): + continue + tag_name = _strip_ref_prefix(remote_ref.name) + if tag_name == ref_text: + sv = parse_semver(tag_name.lstrip("vV")) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=tag_name, + sha=remote_ref.sha, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=sv.is_prerelease if sv else False, + ) + + # Try as full refname + for remote_ref in refs: + if remote_ref.name == ref_text: + short = _strip_ref_prefix(remote_ref.name) + is_branch = remote_ref.name.startswith("refs/heads/") + if is_branch and not self._options.allow_head: + raise HeadNotAllowedError(entry.name, short) + sv = parse_semver(short.lstrip("vV")) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=short, + sha=remote_ref.sha, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=sv.is_prerelease if sv else False, + ) + + # Try as branch name + for remote_ref in refs: + if remote_ref.name == f"refs/heads/{ref_text}": + if not self._options.allow_head: + raise HeadNotAllowedError(entry.name, ref_text) + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=ref_text, + sha=remote_ref.sha, + requested_version=entry.version, + tags=entry.tags, + is_prerelease=False, + ) + + # HEAD special case + if ref_text.upper() == "HEAD": + if not self._options.allow_head: + raise HeadNotAllowedError(entry.name, "HEAD") + + raise RefNotFoundError(entry.name, ref_text, owner_repo) + + def _resolve_version_range( + self, + entry: PackageEntry, + resolver: RefResolver, + owner_repo: str, + yml: MarketplaceYml, + ) -> ResolvedPackage: + """Resolve an entry using its ``version:`` semver range.""" + version_range = entry.version + assert version_range is not None + + # Determine tag pattern: entry > build > default + pattern = entry.tag_pattern or yml.build.tag_pattern + + tag_rx = build_tag_regex(pattern) + refs = resolver.list_remote_refs(owner_repo) + + # Filter tags matching the pattern and extract versions + candidates: list[tuple[SemVer, str, str]] = [] # (semver, tag_name, sha) + for remote_ref in refs: + if not remote_ref.name.startswith("refs/tags/"): + continue + tag_name = remote_ref.name[len("refs/tags/"):] + m = tag_rx.match(tag_name) + if not m: + continue + version_str = m.group("version") + sv = parse_semver(version_str) + if sv is None: + continue + + # Prerelease filter + include_pre = ( + entry.include_prerelease or self._options.include_prerelease + ) + if sv.is_prerelease and not include_pre: + continue + + # Range filter + if satisfies_range(sv, version_range): + candidates.append((sv, tag_name, remote_ref.sha)) + + if not candidates: + raise NoMatchingVersionError( + entry.name, + version_range, + detail=f"pattern='{pattern}', remote='{owner_repo}'", + ) + + # Pick highest + candidates.sort(key=lambda c: c[0], reverse=True) + best_sv, best_tag, best_sha = candidates[0] + + return ResolvedPackage( + name=entry.name, + source_repo=owner_repo, + subdir=entry.subdir, + ref=best_tag, + sha=best_sha, + requested_version=version_range, + tags=entry.tags, + is_prerelease=best_sv.is_prerelease, + ) + + # -- concurrent resolution ---------------------------------------------- + + def resolve(self) -> ResolveResult: + """Resolve every entry concurrently. + + Returns + ------- + ResolveResult + Contains resolved entries and any errors encountered. + + Raises + ------ + BuildError + On any resolution failure (unless ``continue_on_error``). + """ + yml = self._load_yml() + entries = yml.packages + if not entries: + return ResolveResult(entries=(), errors=()) + + results: Dict[int, ResolvedPackage] = {} + errors: List[Tuple[str, str]] = [] + + with ThreadPoolExecutor( + max_workers=min(self._options.concurrency, len(entries)) + ) as pool: + future_to_index = { + pool.submit(self._resolve_entry, entry): idx + for idx, entry in enumerate(entries) + } + for future in as_completed(future_to_index): + idx = future_to_index[future] + entry = entries[idx] + try: + resolved = future.result(timeout=self._options.timeout_seconds) + results[idx] = resolved + except BuildError as exc: + if self._options.continue_on_error: + errors.append((entry.name, str(exc))) + else: + raise + except Exception as exc: # noqa: BLE001 -- thread-pool catch-all wraps to BuildError + logger.debug("Unexpected error resolving '%s'", entry.name, exc_info=True) + if self._options.continue_on_error: + errors.append((entry.name, str(exc))) + else: + raise BuildError( + f"Unexpected error resolving '{entry.name}': {exc}", + package=entry.name, + ) from exc + + # Return in yml order + ordered: List[ResolvedPackage] = [] + for idx in range(len(entries)): + if idx in results: + ordered.append(results[idx]) + return ResolveResult(entries=tuple(ordered), errors=tuple(errors)) + + # -- remote description fetcher ----------------------------------------- + + def _fetch_remote_metadata(self, pkg: ResolvedPackage) -> Optional[Dict[str, str]]: + """Best-effort: fetch ``description`` and ``version`` from the + package's remote ``apm.yml``. + + Returns a dict with ``description`` and/or ``version`` keys, or + ``None`` on any error. This is purely cosmetic enrichment -- + failures are silently logged at debug level and never propagate. + + When a GitHub token is available (via ``self._github_token``), it + is included as an ``Authorization`` header so private repos can be + accessed. + """ + try: + path_prefix = f"{pkg.subdir}/" if pkg.subdir else "" + url = ( + f"https://raw.githubusercontent.com/" + f"{pkg.source_repo}/{pkg.sha}/{path_prefix}apm.yml" + ) + req = urllib.request.Request(url) + if self._github_token: + req.add_header("Authorization", f"token {self._github_token}") + with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 + raw = resp.read().decode("utf-8") + data = yaml.safe_load(raw) + if not isinstance(data, dict): + return None + result: Dict[str, str] = {} + desc = data.get("description") + if isinstance(desc, str) and desc: + result["description"] = desc + ver = data.get("version") + if ver is not None: + ver_str = str(ver).strip() + if ver_str: + result["version"] = ver_str + if result: + logger.debug( + "Fetched metadata for %s from remote apm.yml: %s", + pkg.name, + ", ".join(result.keys()), + ) + return result + except Exception: # noqa: BLE001 -- best-effort enrichment + logger.debug( + "Could not fetch remote metadata for %s", + pkg.name, + exc_info=True, + ) + return None + + def _resolve_github_token(self) -> Optional[str]: + """Resolve a GitHub token using ``AuthResolver``. + + Called once before concurrent fetches. Returns the token string + or ``None`` if no credentials are available. Never raises -- + auth failures are logged at debug and silently ignored. + """ + try: + resolver = self._auth_resolver + if resolver is None: + from ..core.auth import AuthResolver # lazy import + + resolver = AuthResolver() + self._auth_resolver = resolver + ctx = resolver.resolve("github.com") # type: ignore[union-attr] + if ctx.token: + logger.debug("Resolved GitHub token for metadata fetch (source=%s)", ctx.source) + return ctx.token + except Exception: # noqa: BLE001 -- best-effort + logger.debug("Could not resolve GitHub token for metadata fetch", exc_info=True) + return None + + def _prefetch_metadata( + self, resolved: List[ResolvedPackage] + ) -> Dict[str, Dict[str, str]]: + """Concurrently fetch remote metadata for all packages. + + Returns a mapping of ``{package_name: {"description": ..., "version": ...}}`` + for successful fetches. Skipped entirely when ``--offline`` is set. + + A GitHub token is resolved once before spawning worker threads and + stored on ``self._github_token`` for the workers to read. + """ + if self._options.offline: + return {} + + if not resolved: + return {} + + # Resolve token once -- threads read self._github_token (immutable). + self._github_token = self._resolve_github_token() + + results: Dict[str, Dict[str, str]] = {} + workers = min(self._options.concurrency, len(resolved)) + with ThreadPoolExecutor(max_workers=workers) as pool: + future_to_name = { + pool.submit(self._fetch_remote_metadata, pkg): pkg.name + for pkg in resolved + } + for future in as_completed(future_to_name): + name = future_to_name[future] + try: + meta = future.result() + if meta: + results[name] = meta + except Exception: # noqa: BLE001 -- best-effort + pass + return results + + # -- composition -------------------------------------------------------- + + def compose_marketplace_json( + self, resolved: List[ResolvedPackage] + ) -> Dict[str, Any]: + """Produce an Anthropic-compliant marketplace.json dict. + + All APM-only fields are stripped. Key order follows the Anthropic + schema exactly. + + Parameters + ---------- + resolved: + List of resolved packages (from ``resolve()``). + + Returns + ------- + dict + An ``OrderedDict``-style dict ready to be serialised as JSON. + """ + yml = self._load_yml() + + # Pre-fetch metadata (description + version) from remote apm.yml + remote_metadata = self._prefetch_metadata(resolved) + + doc: Dict[str, Any] = OrderedDict() + doc["name"] = yml.name + doc["description"] = yml.description + doc["version"] = yml.version + + # Owner -- omit empty optional sub-fields + owner_dict: Dict[str, Any] = OrderedDict() + owner_dict["name"] = yml.owner.name + if yml.owner.email: + owner_dict["email"] = yml.owner.email + if yml.owner.url: + owner_dict["url"] = yml.owner.url + doc["owner"] = owner_dict + + # Metadata -- pass-through verbatim (only if present) + if yml.metadata: + doc["metadata"] = yml.metadata + + # Plugins (packages -> plugins) + plugins: List[Dict[str, Any]] = [] + for pkg in resolved: + plugin: Dict[str, Any] = OrderedDict() + plugin["name"] = pkg.name + meta = remote_metadata.get(pkg.name, {}) + if meta.get("description"): + plugin["description"] = meta["description"] + if meta.get("version"): + plugin["version"] = meta["version"] + plugin["tags"] = list(pkg.tags) + + source: Dict[str, Any] = OrderedDict() + source["type"] = "github" + source["repository"] = pkg.source_repo + if pkg.subdir: + source["path"] = pkg.subdir + source["ref"] = pkg.ref + source["commit"] = pkg.sha + plugin["source"] = source + + plugins.append(plugin) + + # Defence-in-depth: detect duplicate plugin names and record + # warnings so the command layer can alert the maintainer. + seen_names: Dict[str, str] = {} + build_warnings: list[str] = [] + for p in plugins: + pname = p["name"] + src = p.get("source", {}) + src_label = src.get("path") or src.get("repository", "?") + if pname in seen_names: + build_warnings.append( + f"Duplicate package name '{pname}': " + f"'{seen_names[pname]}' and '{src_label}'. " + f"Consumers will see duplicate entries in browse." + ) + else: + seen_names[pname] = src_label + self._compose_warnings = tuple(build_warnings) + + doc["plugins"] = plugins + return doc + + # -- diff --------------------------------------------------------------- + + @staticmethod + def _compute_diff( + old_json: Optional[Dict[str, Any]], + new_json: Dict[str, Any], + ) -> Tuple[int, int, int, int]: + """Compare old vs new marketplace.json and classify each plugin. + + Returns (unchanged, added, updated, removed) counts. + """ + if old_json is None: + return (0, len(new_json.get("plugins", [])), 0, 0) + + old_plugins: Dict[str, str] = {} + for p in old_json.get("plugins", []): + name = p.get("name", "") + sha = "" + src = p.get("source", {}) + if isinstance(src, dict): + sha = src.get("commit", "") + old_plugins[name] = sha + + new_plugins: Dict[str, str] = {} + for p in new_json.get("plugins", []): + name = p.get("name", "") + sha = "" + src = p.get("source", {}) + if isinstance(src, dict): + sha = src.get("commit", "") + new_plugins[name] = sha + + unchanged = 0 + updated = 0 + added = 0 + removed = 0 + + for name, sha in new_plugins.items(): + if name not in old_plugins: + added += 1 + elif old_plugins[name] == sha: + unchanged += 1 + else: + updated += 1 + + for name in old_plugins: + if name not in new_plugins: + removed += 1 + + return (unchanged, added, updated, removed) + + # -- atomic write ------------------------------------------------------- + + @staticmethod + def _serialize_json(data: Dict[str, Any]) -> str: + """Serialize to JSON with 2-space indent, LF endings, trailing newline.""" + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + @staticmethod + def _atomic_write(path: Path, content: str) -> None: + """Write *content* to *path* atomically via tmp + rename.""" + atomic_write(path, content) + + def _load_existing_json(self, path: Path) -> Optional[Dict[str, Any]]: + """Load existing marketplace.json for diff, or None.""" + if not path.exists(): + return None + try: + text = path.read_text(encoding="utf-8") + return json.loads(text) + except (json.JSONDecodeError, OSError): + return None + + # -- full pipeline ------------------------------------------------------ + + def build(self) -> BuildReport: + """Full pipeline: load -> resolve -> compose -> write. + + Returns + ------- + BuildReport + Summary including diff statistics. + """ + result = self.resolve() + resolved = list(result.entries) + errors = result.errors + + new_json = self.compose_marketplace_json(resolved) + build_warnings = getattr(self, "_compose_warnings", ()) + output_path = self._output_path() + + # Load existing for diff + old_json = self._load_existing_json(output_path) + unchanged, added, updated, removed = self._compute_diff(old_json, new_json) + + # Write (unless dry-run) + if not self._options.dry_run: + output_path.parent.mkdir(parents=True, exist_ok=True) + content = self._serialize_json(new_json) + self._atomic_write(output_path, content) + + # Cleanup resolver + if self._resolver is not None: + self._resolver.close() + + return BuildReport( + resolved=tuple(resolved), + errors=tuple(errors), + warnings=tuple(build_warnings), + unchanged_count=unchanged, + added_count=added, + updated_count=updated, + removed_count=removed, + output_path=output_path, + dry_run=self._options.dry_run, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _strip_ref_prefix(refname: str) -> str: + """Strip ``refs/tags/`` or ``refs/heads/`` prefix.""" + if refname.startswith("refs/tags/"): + return refname[len("refs/tags/"):] + if refname.startswith("refs/heads/"): + return refname[len("refs/heads/"):] + return refname diff --git a/src/apm_cli/marketplace/client.py b/src/apm_cli/marketplace/client.py index bcd0101be..02e8cb0f6 100644 --- a/src/apm_cli/marketplace/client.py +++ b/src/apm_cli/marketplace/client.py @@ -250,7 +250,8 @@ def _do_fetch(token, _git_env): # retrying with a token. unauth_first=False, ) - except Exception as exc: + except Exception as exc: # noqa: BLE001 -- wraps unknown auth/network errors into MarketplaceFetchError + logger.debug("Fetch failed for '%s'", source.name, exc_info=True) raise MarketplaceFetchError(source.name, str(exc)) from exc diff --git a/src/apm_cli/marketplace/errors.py b/src/apm_cli/marketplace/errors.py index c286db461..cf1d7761a 100644 --- a/src/apm_cli/marketplace/errors.py +++ b/src/apm_cli/marketplace/errors.py @@ -31,6 +31,14 @@ def __init__(self, plugin_name: str, marketplace_name: str): ) +class MarketplaceYmlError(MarketplaceError): + """Raised when marketplace.yml validation or parsing fails.""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + class MarketplaceFetchError(MarketplaceError): """Raised when fetching marketplace data fails.""" @@ -42,3 +50,79 @@ def __init__(self, name: str, reason: str = ""): f"Failed to fetch marketplace '{name}'{detail}. " f"Run 'apm marketplace update {name}' to retry." ) + + +# --------------------------------------------------------------------------- +# Builder errors (used by builder.py and ref_resolver.py) +# --------------------------------------------------------------------------- + + +class BuildError(MarketplaceError): + """Base class for errors raised during marketplace build.""" + + def __init__(self, message: str, *, package: str = ""): + self.package = package + super().__init__(message) + + +class NoMatchingVersionError(BuildError): + """No remote tag satisfies the requested semver range.""" + + def __init__(self, package: str, version_range: str, *, detail: str = ""): + self.version_range = version_range + extra = f" ({detail})" if detail else "" + super().__init__( + f"No tag matching version '{version_range}' found for " + f"package '{package}'{extra}", + package=package, + ) + + +class RefNotFoundError(BuildError): + """An explicit ref (tag/branch/SHA) was not found on the remote.""" + + def __init__(self, package: str, ref: str, remote: str): + self.ref = ref + self.remote = remote + super().__init__( + f"Ref '{ref}' not found on remote '{remote}' " + f"for package '{package}'", + package=package, + ) + + +class HeadNotAllowedError(BuildError): + """Resolved ref is HEAD or a branch name and allow_head is False.""" + + def __init__(self, package: str, ref: str): + self.ref = ref + super().__init__( + f"Package '{package}' resolves to branch/HEAD ref '{ref}'. " + f"Branch refs are mutable and not recommended for reproducible builds. " + f"Pin to a tag or SHA, or pass --allow-head to override.", + package=package, + ) + + +class OfflineMissError(BuildError): + """Offline mode requested but the ref cache has no entry for the remote.""" + + def __init__(self, package: str, remote: str): + self.remote = remote + super().__init__( + f"Offline mode: no cached refs for '{remote}' " + f"(package '{package}'). Run a build online first.", + package=package, + ) + + +class GitLsRemoteError(BuildError): + """git ls-remote failed (wraps TranslatedGitError).""" + + def __init__(self, package: str, summary: str, hint: str): + self.summary_text = summary + self.hint = hint + super().__init__( + f"{summary} {hint}", + package=package, + ) diff --git a/src/apm_cli/marketplace/git_stderr.py b/src/apm_cli/marketplace/git_stderr.py new file mode 100644 index 000000000..64d33532a --- /dev/null +++ b/src/apm_cli/marketplace/git_stderr.py @@ -0,0 +1,178 @@ +"""Translate git stderr into actionable, ASCII-only error messages. + +Callers pass captured stderr text, an optional exit code, and context +(operation name, remote). This module classifies the failure into one +of four known modes and returns a structured ``TranslatedGitError`` +with a one-line summary, an actionable hint, and the (truncated) raw +stderr. + +No subprocess, network, filesystem, or logging side effects -- this is +a pure function module. + +Example:: + + >>> from apm_cli.marketplace.git_stderr import translate_git_stderr + >>> err = translate_git_stderr( + ... "fatal: authentication failed for 'https://github.com/acme/tools'", + ... exit_code=128, + ... operation="ls-remote", + ... remote="acme/tools", + ... ) + >>> err.kind.value + 'auth' + +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +_RAW_MAX_LEN = 500 +_SUMMARY_MAX_LEN = 80 + + +class GitErrorKind(Enum): + """Known git failure modes.""" + + AUTH = "auth" + NOT_FOUND = "not_found" + TIMEOUT = "timeout" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class TranslatedGitError: + """Structured result of translating git stderr.""" + + kind: GitErrorKind + summary: str + hint: str + raw: str + + +# -- classification patterns (lower-cased, priority order) ------------------ + +_AUTH_PATTERNS: list[str] = [ + "authentication failed", + "invalid credentials", + "could not read password", + "permission denied (publickey)", + "403 forbidden", + "401 unauthorized", + "fatal: authentication", + "remote: write access", + "please make sure you have the correct access rights", + "the requested url returned error: 401", + "the requested url returned error: 403", +] + +_NOT_FOUND_PATTERNS: list[str] = [ + "repository not found", + "does not appear to be a git repository", + "not a valid ref", + "couldn't find remote ref", + "could not resolve", + "the requested url returned error: 404", + "no such ref", + "unknown ref", +] + +_TIMEOUT_PATTERNS: list[str] = [ + "operation timed out", + "connection timed out", + "could not resolve host", + "connection refused", + "network is unreachable", + "temporary failure in name resolution", + "ssl_read: connection reset", + "early eof", + "rpc failed", +] + + +def _truncate_raw(stderr: str) -> str: + """Keep first ``_RAW_MAX_LEN`` chars; append marker if truncated.""" + if len(stderr) <= _RAW_MAX_LEN: + return stderr + return stderr[:_RAW_MAX_LEN] + "... (truncated)" + + +def _classify(stderr_lower: str) -> GitErrorKind: + """Return the first matching error kind (priority order). + + Priority: AUTH > NOT_FOUND > TIMEOUT > UNKNOWN. + + When a NOT_FOUND pattern is a substring of a more-specific TIMEOUT + pattern (e.g. ``"could not resolve"`` vs ``"could not resolve host"``), + the longer TIMEOUT match wins so that DNS failures are not + misclassified as "not found". + """ + for pattern in _AUTH_PATTERNS: + if pattern in stderr_lower: + return GitErrorKind.AUTH + for pattern in _NOT_FOUND_PATTERNS: + if pattern in stderr_lower: + # "could not resolve host" is a DNS/network issue, not not-found. + if pattern == "could not resolve" and "could not resolve host" in stderr_lower: + continue + return GitErrorKind.NOT_FOUND + for pattern in _TIMEOUT_PATTERNS: + if pattern in stderr_lower: + return GitErrorKind.TIMEOUT + return GitErrorKind.UNKNOWN + + +def _build_summary(kind: GitErrorKind, operation: str, exit_code: int | None) -> str: + """Build a one-line ASCII summary, capped at ``_SUMMARY_MAX_LEN`` chars.""" + if kind == GitErrorKind.AUTH: + text = f"Git authentication failed during {operation}." + elif kind == GitErrorKind.NOT_FOUND: + text = f"Git ref or repository not found during {operation}." + elif kind == GitErrorKind.TIMEOUT: + text = f"Git network timeout during {operation}." + else: + if exit_code is not None: + text = f"Git failed during {operation} (exit {exit_code})." + else: + text = f"Git failed during {operation}." + + if len(text) > _SUMMARY_MAX_LEN: + text = text[: _SUMMARY_MAX_LEN - 3] + "..." + return text + + +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." + ) + 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." + ) + if kind == GitErrorKind.TIMEOUT: + return "Network issue contacting the remote. Retry or check your connection." + # UNKNOWN + return f"Git failed during {operation}. See raw stderr above." + + +def translate_git_stderr( + stderr: str, + *, + exit_code: int | None = None, + operation: str = "git operation", + remote: str | None = None, +) -> TranslatedGitError: + """Classify git stderr text into a known failure mode and produce an actionable hint.""" + kind = _classify(stderr.lower()) + return TranslatedGitError( + kind=kind, + summary=_build_summary(kind, operation, exit_code), + hint=_build_hint(kind, operation, remote), + raw=_truncate_raw(stderr), + ) diff --git a/src/apm_cli/marketplace/init_template.py b/src/apm_cli/marketplace/init_template.py new file mode 100644 index 000000000..acd603db4 --- /dev/null +++ b/src/apm_cli/marketplace/init_template.py @@ -0,0 +1,82 @@ +"""Template renderer for ``apm marketplace init``. + +Produces a richly-commented ``marketplace.yml`` scaffold that is valid +against :func:`~apm_cli.marketplace.yml_schema.load_marketplace_yml`. +""" + +from __future__ import annotations + +# The template uses Python str.format() with named placeholders for +# {name} and {owner}. Literal braces (e.g. in tagPattern) are doubled. + +_TEMPLATE = """\ +# APM marketplace descriptor +# +# This file (marketplace.yml) is the SOURCE for your marketplace. +# Run 'apm marketplace build' to compile it to marketplace.json. +# Both files must be committed to the repository. +# +# For the full schema, see: +# https://microsoft.github.io/apm/guides/marketplace-authoring/ + +name: {name} +description: A short description of what your marketplace offers + +# Semantic version of this marketplace (bump on release) +version: 0.1.0 + +owner: + name: {owner} + url: https://github.com/{owner} + # email: maintainers@{owner}.example # optional + +# APM-only build options (stripped from compiled marketplace.json) +build: + # Default tag pattern used to resolve {{version}} for each package. + # Supports {{name}} and {{version}} placeholders. Override per-package below. + tagPattern: "v{{version}}" + +# Opaque pass-through metadata (copied verbatim to marketplace.json). +# Use this for Anthropic-recognised or marketplace-specific fields. +metadata: + # Example: maintained by {owner} + homepage: https://example.com + +packages: + - name: example-package + description: Human-readable description of the package + source: {owner}/example-package + version: "^1.0.0" + # Optional overrides: + # subdir: path/inside/repo + # tagPattern: "example-package-v{{version}}" + # include_prerelease: false + # ref: abcdef1234 # pin to explicit SHA/tag/branch (overrides version range) + + # Alternative: pin a package to an explicit branch or SHA instead of a + # version range. Uncomment the entry below and remove the 'version' line. + # + # - name: pinned-package + # description: Pinned to a specific commit + # source: {owner}/pinned-package + # ref: main +""" + + +def render_marketplace_yml_template( + name: str | None = None, + owner: str | None = None, +) -> str: + """Return the scaffold content for a new ``marketplace.yml``. + + Parameters + ---------- + name: + Marketplace name. Defaults to ``my-marketplace``. + owner: + Owner name. Defaults to ``acme-org``. + """ + return _TEMPLATE.format( + name=name or "my-marketplace", + owner=owner or "acme-org", + ) diff --git a/src/apm_cli/marketplace/pr_integration.py b/src/apm_cli/marketplace/pr_integration.py new file mode 100644 index 000000000..21dd1aab4 --- /dev/null +++ b/src/apm_cli/marketplace/pr_integration.py @@ -0,0 +1,482 @@ +"""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 typing import Optional + +from ._git_utils import redact_token as _redact_token +from .git_stderr import translate_git_stderr +from .publisher import ConsumerTarget, PublishOutcome, PublishPlan, TargetResult + +__all__ = [ + "PrState", + "PrResult", + "PrIntegrator", +] + +# --------------------------------------------------------------------------- +# 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} " + f"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, + ) -> Optional[dict]: + """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: + 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: + 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 new file mode 100644 index 000000000..4d7b03bd4 --- /dev/null +++ b/src/apm_cli/marketplace/publisher.py @@ -0,0 +1,895 @@ +"""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, field +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Optional + +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, MarketplaceYmlError +from .git_stderr import translate_git_stderr +from .ref_resolver import RefResolver +from .resolver import parse_marketplace_ref +from .semver import parse_semver +from .tag_pattern import render_tag +from .yml_schema import load_marketplace_yml + +logger = logging.getLogger(__name__) + +__all__ = [ + "ConsumerTarget", + "PublishPlan", + "PublishOutcome", + "TargetResult", + "PublishState", + "MarketplacePublisher", +] + +# --------------------------------------------------------------------------- +# 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: Optional[str] = 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: Optional[str] = None + new_version: Optional[str] = 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 + ``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: Optional[RefResolver] = None, + clock: Optional[Callable[[], datetime]] = None, + runner: Optional[Callable[..., subprocess.CompletedProcess]] = 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.yml.""" + if self._yml is None: + yml_path = self._root / "marketplace.yml" + self._yml = load_marketplace_yml(yml_path) + return self._yml + + # -- plan --------------------------------------------------------------- + + def plan( + self, + targets: Sequence[ConsumerTarget], + *, + target_package: Optional[str] = None, + allow_downgrade: bool = False, + allow_ref_change: bool = False, + ) -> PublishPlan: + """Compute a publish plan. + + Reads the local ``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 ``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] + + # 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}" + f"-{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: # noqa: BLE001 -- per-target error isolation + 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: # noqa: BLE001 -- thread-pool future catch-all + 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 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 + apm_yml_path = clone_dir / target.path_in_repo + try: + ensure_path_within(apm_yml_path, clone_dir) + except PathTraversalError: + return TargetResult( + target=target, + outcome=PublishOutcome.FAILED, + message=( + "Path traversal rejected: " + + target.path_in_repo + ), + ) + + if not apm_yml_path.exists(): + return 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 TargetResult( + target=target, + outcome=PublishOutcome.FAILED, + message=( + f"Failed to parse {target.path_in_repo}: {exc}" + ), + ) + + if not isinstance(data, dict): + return TargetResult( + target=target, + outcome=PublishOutcome.FAILED, + message="Invalid apm.yml: expected a mapping", + ) + + # 4. Find matching marketplace entries in dependencies.apm + deps = data.get("dependencies") + if not isinstance(deps, dict): + return 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 TargetResult( + target=target, + outcome=PublishOutcome.FAILED, + message=( + f"Marketplace '{plan.marketplace_name}' not " + "referenced in apm.yml" + ), + ) + + # Parse each entry with parse_marketplace_ref + new_ref = plan.new_ref + mkt_lower = plan.marketplace_name.lower() + matches: list[tuple[int, str, Optional[str], 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")) + + for _idx, _pname, old_ref, entry_str in matches: + if old_ref == new_ref: + continue # Already at target -- no guard needed + + # Ref-change guard + if old_ref is None: + # Implicit latest -> explicit pin + 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: + # Non-semver ref -> semver tag + 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, + ) + + # 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: Optional[str] = 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 + ) + 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: + 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 " + f"{old_label} to {new_ref}" + ) + else: + msg = ( + f"Updated {updated_count} entries for " + f"{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: Optional[str] = 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/ref_resolver.py b/src/apm_cli/marketplace/ref_resolver.py new file mode 100644 index 000000000..b22c3972c --- /dev/null +++ b/src/apm_cli/marketplace/ref_resolver.py @@ -0,0 +1,332 @@ +"""Concurrent git ls-remote driver with in-memory ref cache. + +``RefResolver`` runs ``git ls-remote`` against GitHub remotes, parses +the output, and caches results in memory (TTL 5 minutes) so that +multiple package entries pointing at the same remote only trigger a +single subprocess call. + +Security notes +-------------- +* Tokens embedded in ``https://x-access-token:@`` URLs are + scrubbed from all error messages and exceptions before they leave + this module. +* The ``translate_git_stderr`` helper from ``git_stderr.py`` is used + to classify failures and produce actionable hints. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import threading +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from .errors import GitLsRemoteError, OfflineMissError +from ._git_utils import redact_token as _redact_token +from .git_stderr import translate_git_stderr + +__all__ = [ + "RemoteRef", + "RefCache", + "RefResolver", +] + +# --------------------------------------------------------------------------- +# Dataclass +# --------------------------------------------------------------------------- + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +@dataclass(frozen=True) +class RemoteRef: + """A single ref returned by ``git ls-remote``.""" + + name: str # e.g. "refs/tags/v1.2.0" or "refs/heads/main" + sha: str # 40-char hex SHA + + +# --------------------------------------------------------------------------- +# Cache +# --------------------------------------------------------------------------- + +_DEFAULT_TTL_SECONDS = 300.0 # 5 minutes + + +@dataclass +class _CacheEntry: + refs: List[RemoteRef] + timestamp: float + + +class RefCache: + """In-memory cache keyed on ``owner/repo``. + + TTL defaults to 5 minutes. Not thread-safe on its own; callers + should use external synchronisation (``RefResolver`` does this via + a per-remote lock). + """ + + def __init__(self, ttl_seconds: float = _DEFAULT_TTL_SECONDS) -> None: + self._ttl = ttl_seconds + self._store: Dict[str, _CacheEntry] = {} + + def get(self, owner_repo: str) -> Optional[List[RemoteRef]]: + """Return cached refs or ``None`` on miss / expiry.""" + entry = self._store.get(owner_repo) + if entry is None: + return None + if (time.monotonic() - entry.timestamp) > self._ttl: + del self._store[owner_repo] + return None + return list(entry.refs) + + def put(self, owner_repo: str, refs: List[RemoteRef]) -> None: + """Store *refs* for *owner_repo*.""" + self._store[owner_repo] = _CacheEntry( + refs=list(refs), + timestamp=time.monotonic(), + ) + + def clear(self) -> None: + """Drop all entries.""" + self._store.clear() + + def __len__(self) -> int: + return len(self._store) + + +# --------------------------------------------------------------------------- +# Resolver +# --------------------------------------------------------------------------- + + +def _parse_ls_remote_output(output: str) -> List[RemoteRef]: + """Parse ``git ls-remote`` stdout into a list of ``RemoteRef``.""" + refs: List[RemoteRef] = [] + for line in output.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split("\t", 1) + if len(parts) != 2: + continue + sha, refname = parts[0].strip(), parts[1].strip() + if not _SHA_RE.match(sha): + continue + # Skip peeled tag objects (^{}) + if refname.endswith("^{}"): + continue + refs.append(RemoteRef(name=refname, sha=sha)) + return refs + + +class RefResolver: + """Run ``git ls-remote`` and cache the results. + + Parameters + ---------- + timeout_seconds: + Per-call subprocess timeout. + offline: + When ``True``, only return cached refs; never call ``git``. + stderr_translator_enabled: + When ``True`` (default), stderr from failed ``git`` calls is + classified via ``translate_git_stderr``. + """ + + def __init__( + self, + *, + timeout_seconds: float = 10.0, + offline: bool = False, + stderr_translator_enabled: bool = True, + ) -> None: + self._timeout = timeout_seconds + self._offline = offline + self._stderr_translator = stderr_translator_enabled + self._cache = RefCache() + self._lock = threading.Lock() + # Per-remote locks to serialise calls to the same remote while + # allowing different remotes to proceed in parallel. + self._remote_locks: Dict[str, threading.Lock] = {} + + @property + def cache(self) -> RefCache: + """Expose cache for testing.""" + return self._cache + + def _remote_lock(self, owner_repo: str) -> threading.Lock: + with self._lock: + if owner_repo not in self._remote_locks: + self._remote_locks[owner_repo] = threading.Lock() + return self._remote_locks[owner_repo] + + def list_remote_refs(self, owner_repo: str) -> List[RemoteRef]: + """Fetch all tags and heads from ``https://github.com/.git``. + + Results are cached; subsequent calls for the same remote return + the cached value until the TTL expires. + + Parameters + ---------- + owner_repo: + ``"owner/repo"`` string (no host, no ``.git`` suffix). + + Returns + ------- + list[RemoteRef] + Parsed refs (tags + heads). + + Raises + ------ + OfflineMissError + In offline mode when the cache has no entry. + GitLsRemoteError + When the ``git ls-remote`` subprocess fails. + """ + lock = self._remote_lock(owner_repo) + with lock: + # Check cache first + cached = self._cache.get(owner_repo) + if cached is not None: + return cached + + if self._offline: + raise OfflineMissError(package="", remote=owner_repo) + + url = f"https://github.com/{owner_repo}.git" + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} + try: + result = subprocess.run( + ["git", "ls-remote", "--tags", "--heads", url], + capture_output=True, + text=True, + timeout=self._timeout, + env=env, + ) + except subprocess.TimeoutExpired: + raise GitLsRemoteError( + package="", + summary=f"git ls-remote timed out after {self._timeout}s for '{owner_repo}'.", + hint="Increase --timeout or check your network connection.", + ) + except OSError as exc: + raise GitLsRemoteError( + package="", + summary=f"Failed to run git ls-remote for '{owner_repo}'.", + hint=f"Ensure git is installed and on PATH. Error: {exc}", + ) + + if result.returncode != 0: + stderr = _redact_token(result.stderr) + if self._stderr_translator: + translated = translate_git_stderr( + stderr, + exit_code=result.returncode, + operation="ls-remote", + remote=owner_repo, + ) + raise GitLsRemoteError( + package="", + summary=translated.summary, + hint=translated.hint, + ) + raise GitLsRemoteError( + package="", + summary=f"git ls-remote failed for '{owner_repo}' (exit {result.returncode}).", + hint=_redact_token(stderr[:200]) if stderr else "No stderr output.", + ) + + refs = _parse_ls_remote_output(result.stdout) + self._cache.put(owner_repo, refs) + return refs + + # ----------------------------------------------------------------- + # Single-ref resolution (no cache) + # ----------------------------------------------------------------- + + def resolve_ref_sha(self, owner_repo: str, ref: str = "HEAD") -> str: + """Resolve a single ref to its concrete SHA via ``git ls-remote``. + + Unlike ``list_remote_refs`` this queries a single ref and does + not cache the result (the caller typically stores the SHA + immediately). + + Parameters + ---------- + owner_repo: + ``"owner/repo"`` string (no host, no ``.git`` suffix). + ref: + The ref to resolve (default ``"HEAD"``). + + Returns + ------- + str + 40-char hex SHA. + + Raises + ------ + GitLsRemoteError + When the ref does not exist or the subprocess fails. + """ + url = f"https://github.com/{owner_repo}.git" + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} + try: + result = subprocess.run( + ["git", "ls-remote", url, ref], + capture_output=True, + text=True, + timeout=self._timeout, + env=env, + ) + except subprocess.TimeoutExpired: + raise GitLsRemoteError( + package="", + summary=f"git ls-remote timed out after {self._timeout}s for '{owner_repo}'.", + hint="Increase --timeout or check your network connection.", + ) + except OSError as exc: + raise GitLsRemoteError( + package="", + summary=f"Failed to run git ls-remote for '{owner_repo}'.", + hint=f"Ensure git is installed and on PATH. Error: {exc}", + ) + + if result.returncode != 0: + stderr = _redact_token(result.stderr) + if self._stderr_translator: + translated = translate_git_stderr( + stderr, + exit_code=result.returncode, + operation="ls-remote", + remote=owner_repo, + ) + raise GitLsRemoteError( + package="", + summary=translated.summary, + hint=translated.hint, + ) + raise GitLsRemoteError( + package="", + summary=f"git ls-remote failed for '{owner_repo}' (exit {result.returncode}).", + hint=_redact_token(stderr[:200]) if stderr else "No stderr output.", + ) + + refs = _parse_ls_remote_output(result.stdout) + if not refs: + raise GitLsRemoteError( + package="", + summary=f"Ref '{ref}' not found on remote '{owner_repo}'.", + hint="Check that the ref exists and you have access to the repository.", + ) + return refs[0].sha + + def close(self) -> None: + """Release resources (cache, locks).""" + self._cache.clear() + with self._lock: + self._remote_locks.clear() diff --git a/src/apm_cli/marketplace/semver.py b/src/apm_cli/marketplace/semver.py new file mode 100644 index 000000000..3938dc534 --- /dev/null +++ b/src/apm_cli/marketplace/semver.py @@ -0,0 +1,242 @@ +"""Semver parsing and range matching for marketplace builds. + +Provides a minimal, dependency-free semver implementation that covers the +range formats used by ``marketplace.yml`` version constraints: + +* Exact: ``"1.2.3"`` +* Caret: ``"^1.2.3"`` (compatible with major) +* Tilde: ``"~1.2.3"`` (compatible with minor) +* Comparison: ``">=1.2.3"``, ``">1.2.3"``, ``"<=1.2.3"``, ``"<1.2.3"`` +* Wildcard: ``"1.2.x"`` / ``"1.2.*"`` +* Combined (AND): ``">=1.0.0 <2.0.0"`` + +Prerelease identifiers are compared per the semver 2.0.0 spec: +numeric identifiers sort before alphanumeric, and a prerelease version +always has lower precedence than the same version without a prerelease. +Build metadata is stored but ignored during comparison. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Optional + +__all__ = [ + "SemVer", + "parse_semver", + "satisfies_range", +] + +# --------------------------------------------------------------------------- +# Regex +# --------------------------------------------------------------------------- + +_SEMVER_RE = re.compile( + r"^(\d+)\.(\d+)\.(\d+)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) + +# --------------------------------------------------------------------------- +# Dataclass +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, order=False) +class SemVer: + """Parsed semantic version. + + Instances are frozen, hashable, and support all comparison operators. + Ordering follows the semver 2.0.0 specification. + """ + + major: int + minor: int + patch: int + prerelease: str # empty string means no prerelease + build_meta: str # ignored in comparisons + + @property + def is_prerelease(self) -> bool: + """Return ``True`` when this version carries a prerelease tag.""" + return self.prerelease != "" + + def _cmp_tuple(self) -> tuple: + """Return a tuple suitable for comparison. + + Prerelease versions have lower precedence than their release + counterpart. When both have prerelease identifiers, they are + compared lexicographically by dot-separated identifier. + """ + if not self.prerelease: + # Release: sorts after any prerelease of same major.minor.patch + return (self.major, self.minor, self.patch, 1, ()) + parts: list[tuple[int, int, str]] = [] + for ident in self.prerelease.split("."): + if ident.isdigit(): + parts.append((0, int(ident), "")) + else: + parts.append((1, 0, ident)) + return (self.major, self.minor, self.patch, 0, tuple(parts)) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, SemVer): + return NotImplemented + return self._cmp_tuple() < other._cmp_tuple() + + def __le__(self, other: object) -> bool: + if not isinstance(other, SemVer): + return NotImplemented + return self._cmp_tuple() <= other._cmp_tuple() + + def __gt__(self, other: object) -> bool: + if not isinstance(other, SemVer): + return NotImplemented + return self._cmp_tuple() > other._cmp_tuple() + + def __ge__(self, other: object) -> bool: + if not isinstance(other, SemVer): + return NotImplemented + return self._cmp_tuple() >= other._cmp_tuple() + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SemVer): + return NotImplemented + return self._cmp_tuple() == other._cmp_tuple() + + def __hash__(self) -> int: + return hash(self._cmp_tuple()) + + +# --------------------------------------------------------------------------- +# Parser +# --------------------------------------------------------------------------- + + +def parse_semver(text: str) -> Optional[SemVer]: + """Parse a semver string into a ``SemVer`` instance. + + Returns ``None`` when *text* does not match the semver grammar. + + Examples + -------- + >>> parse_semver("1.2.3") + SemVer(major=1, minor=2, patch=3, prerelease='', build_meta='') + >>> parse_semver("not-a-version") is None + True + """ + m = _SEMVER_RE.match(text) + if not m: + return None + return SemVer( + major=int(m.group(1)), + minor=int(m.group(2)), + patch=int(m.group(3)), + prerelease=m.group(4) or "", + build_meta=m.group(5) or "", + ) + + +# --------------------------------------------------------------------------- +# Range matching +# --------------------------------------------------------------------------- + + +def satisfies_range(version: SemVer, range_spec: str) -> bool: + """Check if *version* satisfies a semver range specification. + + Supported range formats (may be combined with spaces for AND): + + * Exact: ``"1.2.3"`` + * Caret: ``"^1.2.3"`` (``>=1.2.3``, ``<2.0.0``) + * Tilde: ``"~1.2.3"`` (``>=1.2.3``, ``<1.3.0``) + * Wildcard: ``"1.2.x"`` / ``"1.2.*"`` (``>=1.2.0``, ``<1.3.0``) + * Comparison: ``">=1.2.3"``, ``">1.2.3"``, ``"<=1.2.3"``, ``"<1.2.3"`` + * Combined: ``">=1.0.0 <2.0.0"`` (space-separated AND) + + An empty *range_spec* matches everything. + """ + spec = range_spec.strip() + if not spec: + return True + + # Space-separated constraints are AND-ed + parts = spec.split() + if len(parts) > 1: + return all(_satisfies_single(version, p) for p in parts) + return _satisfies_single(version, spec) + + +def _satisfies_single(version: SemVer, spec: str) -> bool: + """Check a single constraint.""" + spec = spec.strip() + if not spec: + return True + + # Caret range: ^major.minor.patch + if spec.startswith("^"): + base = parse_semver(spec[1:]) + if base is None: + return False + if base.major != 0: + # ^1.2.3 := >=1.2.3 <2.0.0 + return version >= base and version.major == base.major + if base.minor != 0: + # ^0.2.3 := >=0.2.3 <0.3.0 + return ( + version >= base + and version.major == 0 + and version.minor == base.minor + ) + # ^0.0.3 := >=0.0.3 <0.0.4 + return ( + version >= base + and version.major == 0 + and version.minor == 0 + and version.patch == base.patch + ) + + # Tilde range: ~major.minor.patch + if spec.startswith("~"): + base = parse_semver(spec[1:]) + if base is None: + return False + # ~1.2.3 := >=1.2.3 <1.3.0 + return ( + version >= base + and version.major == base.major + and version.minor == base.minor + ) + + # Comparison operators + if spec.startswith(">="): + base = parse_semver(spec[2:]) + return base is not None and version >= base + if spec.startswith(">") and not spec.startswith(">="): + base = parse_semver(spec[1:]) + return base is not None and version > base + if spec.startswith("<="): + base = parse_semver(spec[2:]) + return base is not None and version <= base + if spec.startswith("<") and not spec.startswith("<="): + base = parse_semver(spec[1:]) + return base is not None and version < base + + # Wildcard: 1.2.x or 1.2.* + wildcard_match = re.match(r"^(\d+)\.(\d+)\.[xX*]$", spec) + if wildcard_match: + major = int(wildcard_match.group(1)) + minor = int(wildcard_match.group(2)) + return version.major == major and version.minor == minor + + # Exact match + base = parse_semver(spec) + if base is None: + return False + return ( + version.major == base.major + and version.minor == base.minor + and version.patch == base.patch + and version.prerelease == base.prerelease + ) diff --git a/src/apm_cli/marketplace/tag_pattern.py b/src/apm_cli/marketplace/tag_pattern.py new file mode 100644 index 000000000..61829bee8 --- /dev/null +++ b/src/apm_cli/marketplace/tag_pattern.py @@ -0,0 +1,103 @@ +"""Tag-pattern expansion and regex builder for marketplace version tags. + +Marketplace entries may specify a ``tag_pattern`` (e.g. ``"v{version}"`` +or ``"{name}-v{version}"``) that describes how git tags map to semver +versions. This module provides two helpers: + +* ``render_tag`` -- expand ``{name}`` and ``{version}`` placeholders + into a concrete tag string. +* ``build_tag_regex`` -- compile a pattern into a regex that captures + the ``{version}`` portion from an arbitrary tag. + +The pattern engine is intentionally minimal: only ``{version}`` and +``{name}`` are recognised. All other text is treated as literal. +""" + +from __future__ import annotations + +import re + +__all__ = [ + "render_tag", + "build_tag_regex", +] + +# Placeholders we recognise. +_PLACEHOLDER_VERSION = "{version}" +_PLACEHOLDER_NAME = "{name}" + + +def render_tag(pattern: str, *, name: str, version: str) -> str: + """Expand ``{name}`` and ``{version}`` placeholders in *pattern*. + + Parameters + ---------- + pattern: + Tag pattern string, e.g. ``"v{version}"`` or ``"{name}-v{version}"``. + name: + Package name to substitute for ``{name}``. + version: + Version string (e.g. ``"1.2.3"``) to substitute for ``{version}``. + + Returns + ------- + str + The expanded tag string. + """ + result = pattern.replace(_PLACEHOLDER_VERSION, version) + result = result.replace(_PLACEHOLDER_NAME, name) + return result + + +def build_tag_regex(pattern: str) -> re.Pattern[str]: + """Return a compiled regex that captures ``{version}`` from a tag. + + Literal text in *pattern* is escaped so that special regex characters + (e.g. dots, parens) are matched verbatim. ``{version}`` becomes a + named capture group ``(?P...)`` matching a semver-like + string. ``{name}`` becomes a non-capturing wildcard ``[^/]+``. + + Parameters + ---------- + pattern: + Tag pattern string, e.g. ``"v{version}"``. + + Returns + ------- + re.Pattern[str] + Compiled regex with a ``version`` named group. + + Examples + -------- + >>> rx = build_tag_regex("v{version}") + >>> m = rx.match("v1.2.3") + >>> m.group("version") + '1.2.3' + """ + # Split pattern around placeholders, escape literal segments, then + # rejoin with regex fragments. + # + # Strategy: replace placeholders with unique sentinels, escape the + # whole string, then swap sentinels for regex fragments. + _sentinel_version = "\x00VERSION\x00" + _sentinel_name = "\x00NAME\x00" + + temp = pattern.replace(_PLACEHOLDER_VERSION, _sentinel_version) + temp = temp.replace(_PLACEHOLDER_NAME, _sentinel_name) + + escaped = re.escape(temp) + + # Semver-like version capture: digits.digits.digits with optional + # prerelease and build metadata. + _VERSION_RX = ( + r"(?P" + r"\d+\.\d+\.\d+" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r")" + ) + + escaped = escaped.replace(re.escape(_sentinel_version), _VERSION_RX) + escaped = escaped.replace(re.escape(_sentinel_name), r"[^/]+") + + return re.compile(r"^" + escaped + r"$") diff --git a/src/apm_cli/marketplace/version_pins.py b/src/apm_cli/marketplace/version_pins.py index 74fe8f7b9..b643793ea 100644 --- a/src/apm_cli/marketplace/version_pins.py +++ b/src/apm_cli/marketplace/version_pins.py @@ -70,14 +70,30 @@ def _pin_key(marketplace_name: str, plugin_name: str, version: str = "") -> str: # ------------------------------------------------------------------ -def load_ref_pins(pins_dir: Optional[str] = None) -> dict: +def load_ref_pins( + pins_dir: Optional[str] = None, + *, + expect_exists: bool = False, +) -> dict: """Load the ref-pins file from disk. Returns an empty dict when the file is missing or contains invalid JSON. Never raises. + + Args: + pins_dir: Override directory for the pins file. + expect_exists: When ``True`` and the file is missing, a warning + is logged. Use this when the caller previously wrote the + file and its absence is unexpected (possible deletion). """ path = _pins_path(pins_dir) if not os.path.exists(path): + if expect_exists: + logger.warning( + "Version-pins file expected but missing: %s " + "-- ref-swap detection is disabled until pins are rebuilt", + path, + ) return {} try: with open(path, "r") as fh: diff --git a/src/apm_cli/marketplace/yml_editor.py b/src/apm_cli/marketplace/yml_editor.py new file mode 100644 index 000000000..dff1667e6 --- /dev/null +++ b/src/apm_cli/marketplace/yml_editor.py @@ -0,0 +1,268 @@ +"""Round-trip YAML editor for ``marketplace.yml`` package entries. + +Uses ``ruamel.yaml`` (round-trip mode) so that comments, key ordering, +and whitespace are preserved across edits. All mutations follow an +atomic-write-then-revalidate pattern: + +1. Read the file with ``ruamel.yaml``. +2. Mutate the in-memory ``CommentedMap``. +3. Write to a temp file, ``os.fsync()``, ``os.replace()`` over original. +4. Call ``load_marketplace_yml()`` to re-validate. +5. On validation failure, restore the original content and re-raise. +""" + +from __future__ import annotations + +import re +from io import StringIO +from pathlib import Path +from typing import List, Optional + +from ruamel.yaml import YAML + +from ..utils.path_security import PathTraversalError, validate_path_segments +from ._io import atomic_write +from .errors import MarketplaceYmlError +from .yml_schema import SOURCE_RE, load_marketplace_yml + +__all__ = [ + "add_plugin_entry", + "update_plugin_entry", + "remove_plugin_entry", +] + + +# ------------------------------------------------------------------- +# Internal helpers +# ------------------------------------------------------------------- + + +def _rt_yaml() -> YAML: + """Return a round-trip ``YAML`` instance with consistent settings.""" + yml = YAML(typ="rt") + yml.preserve_quotes = True + return yml + + +def _load_rt(yml_path: Path): + """Load *yml_path* with ruamel round-trip mode. + + Returns the ``CommentedMap`` root document. + """ + text = yml_path.read_text(encoding="utf-8") + return _rt_yaml().load(text), text + + +def _dump_rt(data) -> str: + """Dump a ruamel ``CommentedMap`` back to a YAML string.""" + stream = StringIO() + _rt_yaml().dump(data, stream) + return stream.getvalue() + + +def _write_and_validate(yml_path: Path, data, original_text: str) -> None: + """Atomically write *data* and re-validate. + + If validation fails the original content is restored and the + ``MarketplaceYmlError`` is re-raised. + """ + new_text = _dump_rt(data) + atomic_write(yml_path, new_text) + try: + load_marketplace_yml(yml_path) + except MarketplaceYmlError: + # Restore original content before propagating. + atomic_write(yml_path, original_text) + raise + + +def _find_entry_index(packages, name: str) -> int: + """Return the index of the entry whose ``name`` matches (case-insensitive). + + Raises ``MarketplaceYmlError`` if not found. + """ + lower = name.lower() + for idx, entry in enumerate(packages): + entry_name = entry.get("name", "") + if isinstance(entry_name, str) and entry_name.lower() == lower: + return idx + raise MarketplaceYmlError( + f"Package '{name}' not found in marketplace.yml" + ) + + +def _validate_source(source: str) -> None: + """Validate that *source* has ``owner/repo`` shape.""" + if not SOURCE_RE.match(source): + raise MarketplaceYmlError( + f"'source' must match '/' shape, got '{source}'" + ) + try: + validate_path_segments(source, context="source") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + +def _validate_subdir(subdir: str) -> None: + """Validate *subdir* for path traversal.""" + try: + validate_path_segments(subdir, context="subdir") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + +# ------------------------------------------------------------------- +# Public API +# ------------------------------------------------------------------- + + +def add_plugin_entry( + yml_path: Path, + *, + source: str, + name: Optional[str] = None, + version: Optional[str] = None, + ref: Optional[str] = None, + subdir: Optional[str] = None, + tag_pattern: Optional[str] = None, + tags: Optional[List[str]] = None, + include_prerelease: bool = False, +) -> str: + """Append a new entry to ``packages[]``. + + Returns the resolved package name. + """ + # --- input validation --- + _validate_source(source) + + if version is not None and ref is not None: + raise MarketplaceYmlError( + "Cannot specify both 'version' and 'ref' -- pick one" + ) + if version is None and ref is None: + raise MarketplaceYmlError( + "At least one of 'version' or 'ref' must be provided" + ) + + if subdir is not None: + _validate_subdir(subdir) + + # Derive name from source repo if not provided. + if name is None: + name = source.split("/", 1)[1] + + # --- load --- + data, original_text = _load_rt(yml_path) + packages = data.get("packages") + if packages is None: + from ruamel.yaml.comments import CommentedSeq + + packages = CommentedSeq() + data["packages"] = packages + + # Duplicate check (case-insensitive). + lower = name.lower() + for entry in packages: + entry_name = entry.get("name", "") + if isinstance(entry_name, str) and entry_name.lower() == lower: + raise MarketplaceYmlError( + f"Package '{name}' already exists in marketplace.yml" + ) + + # --- build entry mapping --- + from ruamel.yaml.comments import CommentedMap + + new_entry = CommentedMap() + new_entry["name"] = name + new_entry["source"] = source + + if version is not None: + new_entry["version"] = version + if ref is not None: + new_entry["ref"] = ref + if subdir is not None: + new_entry["subdir"] = subdir + if tag_pattern is not None: + new_entry["tag_pattern"] = tag_pattern + if include_prerelease: + new_entry["include_prerelease"] = True + if tags is not None and len(tags) > 0: + new_entry["tags"] = tags + + packages.append(new_entry) + + # --- write + validate --- + _write_and_validate(yml_path, data, original_text) + return name + + +def update_plugin_entry(yml_path: Path, name: str, **fields) -> None: + """Update fields on an existing ``packages[]`` entry by name. + + Only fields that are explicitly provided (not ``None``) are updated. + """ + data, original_text = _load_rt(yml_path) + packages = data.get("packages") + if packages is None: + raise MarketplaceYmlError( + f"Package '{name}' not found in marketplace.yml" + ) + + idx = _find_entry_index(packages, name) + entry = packages[idx] + + # Version / ref mutual exclusion: setting one clears the other. + has_version = "version" in fields and fields["version"] is not None + has_ref = "ref" in fields and fields["ref"] is not None + + if has_version and has_ref: + raise MarketplaceYmlError( + "Cannot specify both 'version' and 'ref' -- pick one" + ) + + if has_version: + entry["version"] = fields["version"] + # Clear ref if present. + if "ref" in entry: + del entry["ref"] + + if has_ref: + entry["ref"] = fields["ref"] + # Clear version if present. + if "version" in entry: + del entry["version"] + + # Simple scalar fields. + _SIMPLE_FIELDS = ("subdir", "tag_pattern") + for key in _SIMPLE_FIELDS: + if key in fields and fields[key] is not None: + if key == "subdir": + _validate_subdir(fields[key]) + entry[key] = fields[key] + + # Boolean field: include_prerelease. + if "include_prerelease" in fields and fields["include_prerelease"] is not None: + entry["include_prerelease"] = fields["include_prerelease"] + + # List field: tags. + if "tags" in fields and fields["tags"] is not None: + entry["tags"] = fields["tags"] + + # --- write + validate --- + _write_and_validate(yml_path, data, original_text) + + +def remove_plugin_entry(yml_path: Path, name: str) -> None: + """Remove a ``packages[]`` entry by name (case-insensitive match).""" + data, original_text = _load_rt(yml_path) + packages = data.get("packages") + if packages is None: + raise MarketplaceYmlError( + f"Package '{name}' not found in marketplace.yml" + ) + + idx = _find_entry_index(packages, name) + del packages[idx] + + # --- write + validate --- + _write_and_validate(yml_path, data, original_text) diff --git a/src/apm_cli/marketplace/yml_schema.py b/src/apm_cli/marketplace/yml_schema.py new file mode 100644 index 000000000..12edf9fa8 --- /dev/null +++ b/src/apm_cli/marketplace/yml_schema.py @@ -0,0 +1,473 @@ +"""Dataclasses, loader, and validation for ``marketplace.yml``. + +``marketplace.yml`` is the maintainer-authored source file that the +``mkt-builder`` compiles into an Anthropic-compliant ``marketplace.json``. +This module is responsible for parsing the YAML, enforcing structural +constraints, and producing immutable dataclass instances that downstream +code can inspect without further validation. + +Key design rules +---------------- +* **Anthropic pass-through preservation.** The ``metadata`` block is + stored as a plain ``dict`` with original key casing (e.g. + ``pluginRoot`` stays ``pluginRoot``). Unknown keys inside ``metadata`` + are preserved -- only the builder decides what is forwarded. +* **APM-only vs Anthropic separation.** Build-time fields (``build``, + ``version``, ``ref``, ``subdir``, ``tag_pattern``, + ``include_prerelease``) live as explicit dataclass attributes so the + builder can strip them cleanly. +* **Strict top-level and per-entry key sets.** Unknown keys raise + ``MarketplaceYmlError`` immediately so that typos are never silently + ignored. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +from ..utils.path_security import PathTraversalError, validate_path_segments +from .errors import MarketplaceYmlError + +__all__ = [ + "MarketplaceYml", + "MarketplaceOwner", + "MarketplaceBuild", + "PackageEntry", + "MarketplaceYmlError", + "SOURCE_RE", + "load_marketplace_yml", +] + +# --------------------------------------------------------------------------- +# Semver validation (matches codebase convention -- regex, no external lib) +# --------------------------------------------------------------------------- + +_SEMVER_RE = re.compile( + r"^\d+\.\d+\.\d+" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + +# ``owner/repo`` shape -- at least one char on each side of the slash. +# Used by both yml_schema and yml_editor for source field validation. +SOURCE_RE = re.compile(r"^[^/]+/[^/]+$") + +# Placeholder tokens accepted in ``tag_pattern`` / ``build.tagPattern``. +_TAG_PLACEHOLDERS = ("{version}", "{name}") + +# --------------------------------------------------------------------------- +# Permitted key sets (strict mode) +# --------------------------------------------------------------------------- + +_TOP_LEVEL_KEYS = frozenset({ + "name", + "description", + "version", + "owner", + "output", + "metadata", + "build", + "packages", +}) + +_BUILD_KEYS = frozenset({ + "tagPattern", +}) + +_PACKAGE_ENTRY_KEYS = frozenset({ + "name", + "source", + "subdir", + "version", + "ref", + "tag_pattern", + "include_prerelease", + "description", + "tags", +}) +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class MarketplaceOwner: + """Owner block of ``marketplace.yml``.""" + + name: str + email: Optional[str] = None + url: Optional[str] = None + + +@dataclass(frozen=True) +class MarketplaceBuild: + """APM-only build configuration block.""" + + tag_pattern: str = "v{version}" + + +@dataclass(frozen=True) +class PackageEntry: + """A single entry in the ``packages`` list. + + Attributes that are Anthropic pass-through (``description``, + ``tags``) are stored alongside APM-only attributes (``subdir``, + ``version``, ``ref``, ``tag_pattern``, ``include_prerelease``) so + the builder can partition them at compile time. + """ + + name: str + source: str + # APM-only fields + subdir: Optional[str] = None + version: Optional[str] = None + ref: Optional[str] = None + tag_pattern: Optional[str] = None + include_prerelease: bool = False + # Anthropic pass-through fields + tags: Tuple[str, ...] = () + + +@dataclass(frozen=True) +class MarketplaceYml: + """Top-level representation of a parsed ``marketplace.yml``. + + ``metadata`` is stored as a plain ``dict`` preserving the original + key casing so the builder can forward it verbatim to + ``marketplace.json``. + """ + + name: str + description: str + version: str + owner: MarketplaceOwner + output: str = "marketplace.json" + metadata: Dict[str, Any] = field(default_factory=dict) + build: MarketplaceBuild = field(default_factory=MarketplaceBuild) + packages: Tuple[PackageEntry, ...] = () + + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + + +def _require_str( + data: Dict[str, Any], + key: str, + *, + context: str = "", +) -> str: + """Return a non-empty string value or raise ``MarketplaceYmlError``.""" + path = f"{context}.{key}" if context else key + value = data.get(key) + if value is None: + raise MarketplaceYmlError(f"'{path}' is required") + if not isinstance(value, str) or not value.strip(): + raise MarketplaceYmlError( + f"'{path}' must be a non-empty string" + ) + return value.strip() + + +def _validate_semver(version: str, *, context: str = "version") -> None: + """Raise if *version* is not a valid semver string.""" + if not _SEMVER_RE.match(version): + raise MarketplaceYmlError( + f"'{context}' value '{version}' is not valid semver (expected x.y.z)" + ) + + +def _validate_source(source: str, *, index: int) -> None: + """Validate ``source`` field shape and path safety.""" + ctx = f"packages[{index}].source" + if not SOURCE_RE.match(source): + raise MarketplaceYmlError( + f"'{ctx}' must match '/' shape, got '{source}'" + ) + try: + validate_path_segments(source, context=ctx) + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + +def _validate_tag_pattern(pattern: str, *, context: str) -> None: + """Ensure *pattern* contains at least one recognised placeholder.""" + if not any(ph in pattern for ph in _TAG_PLACEHOLDERS): + raise MarketplaceYmlError( + f"'{context}' must contain at least one of " + f"{', '.join(_TAG_PLACEHOLDERS)}, got '{pattern}'" + ) + + +def _check_unknown_keys( + data: Dict[str, Any], + permitted: frozenset, + *, + context: str, +) -> None: + """Raise on any key not in *permitted*.""" + unknown = set(data.keys()) - permitted + if unknown: + sorted_unknown = sorted(unknown) + sorted_permitted = sorted(permitted) + raise MarketplaceYmlError( + f"Unknown key(s) in {context}: {', '.join(sorted_unknown)}. " + f"Permitted keys: {', '.join(sorted_permitted)}" + ) + + +# --------------------------------------------------------------------------- +# Internal parse helpers +# --------------------------------------------------------------------------- + + +def _parse_owner(raw: Any) -> MarketplaceOwner: + """Parse and validate the ``owner`` block.""" + if not isinstance(raw, dict): + raise MarketplaceYmlError( + "'owner' must be a mapping with at least a 'name' key" + ) + name = _require_str(raw, "name", context="owner") + email = raw.get("email") + if email is not None: + email = str(email).strip() or None + url = raw.get("url") + if url is not None: + url = str(url).strip() or None + return MarketplaceOwner(name=name, email=email, url=url) + + +def _parse_build(raw: Any) -> MarketplaceBuild: + """Parse and validate the ``build`` block.""" + if raw is None: + return MarketplaceBuild() + if not isinstance(raw, dict): + raise MarketplaceYmlError("'build' must be a mapping") + _check_unknown_keys(raw, _BUILD_KEYS, context="build") + tag_pattern = raw.get("tagPattern", "v{version}") + if not isinstance(tag_pattern, str) or not tag_pattern.strip(): + raise MarketplaceYmlError( + "'build.tagPattern' must be a non-empty string" + ) + tag_pattern = tag_pattern.strip() + _validate_tag_pattern(tag_pattern, context="build.tagPattern") + return MarketplaceBuild(tag_pattern=tag_pattern) + + +def _parse_package_entry(raw: Any, index: int) -> PackageEntry: + """Parse and validate a single ``packages`` entry.""" + if not isinstance(raw, dict): + raise MarketplaceYmlError( + f"packages[{index}] must be a mapping" + ) + + # -- strict key check -- + _check_unknown_keys(raw, _PACKAGE_ENTRY_KEYS, context=f"packages[{index}]") + + name = _require_str(raw, "name", context=f"packages[{index}]") + source = _require_str(raw, "source", context=f"packages[{index}]") + _validate_source(source, index=index) + + # APM-only: subdir + subdir: Optional[str] = raw.get("subdir") + if subdir is not None: + if not isinstance(subdir, str) or not subdir.strip(): + raise MarketplaceYmlError( + f"'packages[{index}].subdir' must be a non-empty string" + ) + subdir = subdir.strip() + try: + validate_path_segments(subdir, context=f"packages[{index}].subdir") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + # APM-only: version (semver range -- stored as string, not parsed here) + version: Optional[str] = raw.get("version") + if version is not None: + version = str(version).strip() + if not version: + raise MarketplaceYmlError( + f"'packages[{index}].version' must be a non-empty string" + ) + + # APM-only: ref + ref: Optional[str] = raw.get("ref") + if ref is not None: + ref = str(ref).strip() + if not ref: + raise MarketplaceYmlError( + f"'packages[{index}].ref' must be a non-empty string" + ) + + # At least one of version or ref must be present + if version is None and ref is None: + raise MarketplaceYmlError( + f"packages[{index}] ('{name}'): at least one of " + f"'version' or 'ref' must be set" + ) + + # APM-only: tag_pattern + tag_pattern: Optional[str] = raw.get("tag_pattern") + if tag_pattern is not None: + if not isinstance(tag_pattern, str) or not tag_pattern.strip(): + raise MarketplaceYmlError( + f"'packages[{index}].tag_pattern' must be a non-empty string" + ) + tag_pattern = tag_pattern.strip() + _validate_tag_pattern( + tag_pattern, context=f"packages[{index}].tag_pattern" + ) + + # APM-only: include_prerelease + include_prerelease = raw.get("include_prerelease", False) + if not isinstance(include_prerelease, bool): + raise MarketplaceYmlError( + f"'packages[{index}].include_prerelease' must be a boolean" + ) + + # Anthropic pass-through: tags + raw_tags = raw.get("tags") + tags: Tuple[str, ...] = () + if raw_tags is not None: + if not isinstance(raw_tags, list): + raise MarketplaceYmlError( + f"'packages[{index}].tags' must be a list of strings" + ) + tags = tuple(str(t) for t in raw_tags) + + return PackageEntry( + name=name, + source=source, + subdir=subdir, + version=version, + ref=ref, + tag_pattern=tag_pattern, + include_prerelease=include_prerelease, + tags=tags, + ) + + +# --------------------------------------------------------------------------- +# Public loader +# --------------------------------------------------------------------------- + + +def load_marketplace_yml(path: Path) -> MarketplaceYml: + """Load and validate a ``marketplace.yml`` file. + + Parameters + ---------- + path : Path + Filesystem path to the YAML file. + + Returns + ------- + MarketplaceYml + Fully validated, immutable representation. + + Raises + ------ + MarketplaceYmlError + On any validation failure or YAML parse error. + """ + # -- read + parse YAML -- + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise MarketplaceYmlError( + f"Cannot read '{path}': {exc}" + ) from exc + + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + # Include line number when the YAML library provides it. + detail = "" + if hasattr(exc, "problem_mark") and exc.problem_mark is not None: + mark = exc.problem_mark + detail = f" (line {mark.line + 1}, column {mark.column + 1})" + raise MarketplaceYmlError( + f"YAML parse error in '{path}'{detail}: {exc}" + ) from exc + + if not isinstance(data, dict): + raise MarketplaceYmlError( + f"'{path}' must contain a YAML mapping at the top level" + ) + + # -- strict top-level key check -- + _check_unknown_keys(data, _TOP_LEVEL_KEYS, context="top level") + + # -- required scalars -- + name = _require_str(data, "name") + description = _require_str(data, "description") + version_str = _require_str(data, "version") + _validate_semver(version_str, context="version") + + # -- owner -- + raw_owner = data.get("owner") + if raw_owner is None: + raise MarketplaceYmlError("'owner' is required") + owner = _parse_owner(raw_owner) + + # -- output -- + output = data.get("output", "marketplace.json") + if not isinstance(output, str) or not output.strip(): + raise MarketplaceYmlError( + "'output' must be a non-empty string" + ) + output = output.strip() + + # Path-traversal guard -- reject output paths containing ".." segments. + try: + validate_path_segments(output, context="marketplace.yml output") + except PathTraversalError as exc: + raise MarketplaceYmlError(str(exc)) from exc + + # -- metadata (Anthropic pass-through, preserve verbatim) -- + metadata: Dict[str, Any] = {} + raw_metadata = data.get("metadata") + if raw_metadata is not None: + if not isinstance(raw_metadata, dict): + raise MarketplaceYmlError("'metadata' must be a mapping") + metadata = dict(raw_metadata) + + # -- build -- + build = _parse_build(data.get("build")) + + # -- packages -- + raw_packages = data.get("packages") + if raw_packages is None: + raw_packages = [] + if not isinstance(raw_packages, list): + raise MarketplaceYmlError("'packages' must be a list") + + entries: List[PackageEntry] = [] + seen_names: Dict[str, int] = {} + for idx, raw_entry in enumerate(raw_packages): + entry = _parse_package_entry(raw_entry, idx) + lower_name = entry.name.lower() + if lower_name in seen_names: + raise MarketplaceYmlError( + f"Duplicate package name '{entry.name}' " + f"(packages[{seen_names[lower_name]}] and packages[{idx}])" + ) + seen_names[lower_name] = idx + entries.append(entry) + + return MarketplaceYml( + name=name, + description=description, + version=version_str, + owner=owner, + output=output, + metadata=metadata, + build=build, + packages=tuple(entries), + ) diff --git a/tests/fixtures/marketplace/golden.json b/tests/fixtures/marketplace/golden.json new file mode 100644 index 000000000..5e2c5d22b --- /dev/null +++ b/tests/fixtures/marketplace/golden.json @@ -0,0 +1,42 @@ +{ + "name": "acme-tools", + "description": "Curated developer tools by Acme Corp", + "version": "1.0.0", + "owner": { + "name": "Acme Corp", + "email": "tools@acme.example.com", + "url": "https://acme.example.com" + }, + "metadata": { + "pluginRoot": "plugins", + "category": "developer-tools" + }, + "plugins": [ + { + "name": "code-reviewer", + "tags": [ + "review", + "quality" + ], + "source": { + "type": "github", + "repository": "acme/code-reviewer", + "ref": "v2.1.0", + "commit": "abcd234567890abcdef1234567890abcdef12345" + } + }, + { + "name": "test-generator", + "tags": [ + "testing" + ], + "source": { + "type": "github", + "repository": "acme/test-generator", + "path": "src/plugin", + "ref": "v1.0.3", + "commit": "def4567890abcdef1234567890abcdef12345678" + } + } + ] +} diff --git a/tests/integration/marketplace/README.md b/tests/integration/marketplace/README.md new file mode 100644 index 000000000..40f772d9a --- /dev/null +++ b/tests/integration/marketplace/README.md @@ -0,0 +1,181 @@ +# Marketplace Integration Test Suite + +This document describes the three-tier test strategy for the +`apm marketplace` command group. It is intended for maintainers +and contributors who need to run, extend, or triage marketplace tests. + +--- + +## 1. Test Tier Overview + +### Tier 1 -- Unit (tests/unit/marketplace/) + +Scope: every function, class, and edge-case in the marketplace library +modules (`builder`, `ref_resolver`, `yml_schema`, `semver`, +`tag_pattern`, `publisher`, `pr_integration`, `init_template`, +`git_stderr`, `errors`). + +All external I/O is replaced by mocks. `git ls-remote` is never called. +No files are created on disk. + +Run command: + + uv run pytest tests/unit/marketplace/ -x -q + +Expected runtime: < 10 seconds. + +### Tier 2 -- Integration (this directory) + +Scope: end-to-end CLI command behaviour on a real temp filesystem, with +`git ls-remote` replaced by a patch on `RefResolver.list_remote_refs`. + +What is tested: +- Real YAML parsing of `marketplace.yml` written to a tmp_path. +- Actual `MarketplaceBuilder` pipeline (load, resolve via mock, compose, + write) producing a `marketplace.json` on disk. +- CLI exit codes, stdout/stderr content, and `marketplace.json` content. +- Anthropic golden-file assertion: canonical input -> byte-exact output + against `tests/fixtures/marketplace/golden.json`. +- Dry-run flag (file not written), schema errors (exit 2), offline mode. +- `init`, `outdated`, `check`, `doctor`, and `publish` command paths. + +What is NOT tested here: +- Real network calls to github.com. +- Real `gh` CLI invocations for PRs. +- Any secrets or tokens. + +Run command: + + uv run pytest tests/integration/marketplace/ -x -q + +Expected runtime: < 30 seconds. + +### Tier 3 -- Live e2e (test_live_e2e.py) + +Scope: full round-trip against a real remote marketplace repository on +GitHub. Requires the `APM_E2E_MARKETPLACE` environment variable. + +What is tested: +- `apm marketplace build` resolves real tags from the remote repo. +- `apm marketplace outdated` reports upgrades correctly. +- `apm marketplace check` exits 0 for reachable entries. +- `apm marketplace doctor` exits 0 with git and network available. + +What is NOT tested here: +- `apm marketplace publish` -- publish writes to third-party repos and + is intrinsically destructive. Publish coverage stays at unit and + integration tiers with a mocked publisher and PR integrator. + +Default behaviour: ALL live tests are skipped when `APM_E2E_MARKETPLACE` +is unset. CI never fails because of missing env vars. + +Run command (maintainer only): + + export APM_E2E_MARKETPLACE=owner/your-marketplace-repo + uv run pytest tests/integration/marketplace/test_live_e2e.py -v + +Expected runtime: 30-120 seconds (depends on network and remote ref count). + +--- + +## 2. Why the Live Tier Is Env-Var-Gated + +The live tier runs `git ls-remote` against a real GitHub remote. This: + +- Consumes GitHub anonymous rate limits (60 req/hour) in CI. +- Requires a repo that stays stable and public. +- Is non-deterministic: tags on the remote can change. + +By gating on `APM_E2E_MARKETPLACE`, the live tests are: +- Invisible to CI unless a maintainer explicitly opts in. +- Safe to run locally against any marketplace repo the maintainer controls. +- Documented in a single env var so they are easy to discover. + +The `live_marketplace_repo` fixture in `conftest.py` validates that the +env var value is in `owner/repo` format and raises `pytest.skip` with a +clear message if the var is absent. + +--- + +## 3. Coverage Matrix + +| Command | Unit | Integration | Live e2e | +|---------|------|-------------|----------| +| build | X | X | E | +| outdated| X | X | E | +| check | X | X | E | +| init | X | X | -- | +| doctor | X | X | E | +| publish | X | X | -- | + +Key: +- X = covered. +- -- = not applicable (init and publish have no safe live test). +- E = env-var-gated (`APM_E2E_MARKETPLACE` must be set). + +--- + +## 4. Anthropic Compliance Check + +The golden-file assertion is the contract between `MarketplaceBuilder` +and the Anthropic schema. The fixture lives at: + + tests/fixtures/marketplace/golden.json + +The integration test `test_build_integration.py::TestBuildGoldenFile` +writes a canonical `marketplace.yml` input, runs the full build +pipeline with refs mocked to return the exact SHAs in the fixture, and +then asserts byte-level equality between the produced `marketplace.json` +and the golden file. + +If the golden file is updated, the integration test must be re-run to +confirm the new file still passes. The key ordering contract is: +`name -> description -> version -> owner -> metadata -> plugins`. +Each plugin must have `name -> (description) -> tags -> source`. +Each source must have `type -> repository -> (path) -> ref -> commit`. + +APM-only keys (`subdir`, `version`, `ref` in yml, `tag_pattern`, +`include_prerelease`) must NEVER appear in `marketplace.json`. + +--- + +## 5. Failure Triage Guide + +| Symptom | First suspect | Action | +|---------|--------------|--------| +| Unit tests fail | Library logic | Check the specific unit test file in tests/unit/marketplace/. | +| Integration tests fail on yml parse | yml_schema.py | Confirm the test fixture YAML is valid. | +| Integration tests fail on JSON content | builder.compose_marketplace_json | Check key order and golden fixture. | +| Integration tests fail on exit code | CLI command handler | Inspect the sys.exit() paths in commands/marketplace.py. | +| Integration tests fail on mock | conftest.py fixture | Confirm mock_ref_resolver patches the right import path. | +| Live tests fail on resolution | Real remote | Check that APM_E2E_MARKETPLACE points to a valid repo with tags. | +| Live tests fail on timeout | Network or rate limit | Increase timeout or set GITHUB_TOKEN to raise rate limit. | +| Golden file mismatch | Builder output | Compare actual vs golden with `diff`. Update golden if intentional. | + +--- + +## 6. Adding a New Test + +1. Identify the tier: does it need real disk I/O? -> integration. Does it + need a real remote? -> live. Otherwise -> unit. +2. Use the fixtures from `conftest.py` (`mkt_repo_root`, `mock_ref_resolver`, + `live_marketplace_repo`). +3. Follow the one-style-per-file convention: use `subprocess.run` (via + `run_cli`) when the test needs real CWD/env handling; use `CliRunner` + when the test only needs to inspect output strings. +4. Assert both the exit code and the output content. +5. Name tests as `test__` for discoverability. + +--- + +## 7. Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| APM_E2E_MARKETPLACE | Enables live tier; value is `owner/repo`. | Unset (skips live tests). | +| GITHUB_TOKEN | Raises GitHub rate limit from 60 to 5000 req/hour. | Unset (anonymous). | + +--- + +_This file is maintained alongside the test suite. Update it when +adding new commands or tiers._ diff --git a/tests/integration/marketplace/__init__.py b/tests/integration/marketplace/__init__.py new file mode 100644 index 000000000..f15c789e9 --- /dev/null +++ b/tests/integration/marketplace/__init__.py @@ -0,0 +1,6 @@ +# Integration tests for apm marketplace commands. +# +# Test tiers: +# unit -- tests/unit/marketplace/ (mocked, fast) +# integration -- this directory (real disk, mocked network) +# live e2e -- test_live_e2e.py (env-var-gated, real network) diff --git a/tests/integration/marketplace/conftest.py b/tests/integration/marketplace/conftest.py new file mode 100644 index 000000000..b0e84b263 --- /dev/null +++ b/tests/integration/marketplace/conftest.py @@ -0,0 +1,355 @@ +"""Shared fixtures for the marketplace integration test suite. + +Conventions +----------- +* All fixtures use tmp_path for filesystem isolation. +* git ls-remote is never called; RefResolver.list_remote_refs is patched + via mock_ref_resolver where network access would otherwise be needed. +* The live_marketplace_repo fixture skips tests when APM_E2E_MARKETPLACE + is not set. It is used exclusively in test_live_e2e.py. +* run_cli invokes the real apm binary via subprocess so that CWD, env + isolation, and exit codes are all captured faithfully. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Optional +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.marketplace.ref_resolver import RemoteRef + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Path to the project root (two parents up from tests/integration/marketplace) +_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent + +# Path to the golden fixture +_GOLDEN_PATH = ( + _PROJECT_ROOT / "tests" / "fixtures" / "marketplace" / "golden.json" +) + +# Environment variable that gates the live e2e tests +_LIVE_ENV_VAR = "APM_E2E_MARKETPLACE" + + +# --------------------------------------------------------------------------- +# Minimal valid marketplace.yml content +# --------------------------------------------------------------------------- + +MINIMAL_YML = """\ +name: test-marketplace +description: Test marketplace for integration tests +version: 1.0.0 +owner: + name: Test Org + email: test@example.com + url: https://example.com +metadata: + pluginRoot: plugins + category: testing +packages: + - name: code-reviewer + description: Automated code review assistant + source: acme/code-reviewer + version: "^2.0.0" + tags: + - review + - quality + - name: test-generator + description: Test generation tool + source: acme/test-generator + version: "^1.0.0" + subdir: src/plugin + tags: + - testing +""" + +# marketplace.yml that matches the golden.json fixture exactly +# (SHAs are injected by mock_ref_resolver_golden) +GOLDEN_YML = """\ +name: acme-tools +description: Curated developer tools by Acme Corp +version: 1.0.0 +owner: + name: Acme Corp + email: tools@acme.example.com + url: https://acme.example.com +metadata: + pluginRoot: plugins + category: developer-tools +packages: + - name: code-reviewer + description: Automated code review assistant + source: acme/code-reviewer + version: "^2.0.0" + tags: + - review + - quality + - name: test-generator + source: acme/test-generator + version: "^1.0.0" + subdir: src/plugin + tags: + - testing +""" + + +# --------------------------------------------------------------------------- +# Core filesystem fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mkt_repo_root(tmp_path: Path) -> Path: + """Return a tmp directory containing a valid marketplace.yml. + + The directory is suitable as the CWD for ``run_cli`` calls and for + constructing a ``MarketplaceBuilder`` directly. + """ + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(MINIMAL_YML, encoding="utf-8") + return tmp_path + + +@pytest.fixture() +def golden_marketplace_json() -> dict: + """Load tests/fixtures/marketplace/golden.json and return as a dict.""" + if not _GOLDEN_PATH.exists(): + pytest.skip( + f"Golden fixture not found at {_GOLDEN_PATH}. " + "Ensure tests/fixtures/marketplace/golden.json is present." + ) + with open(_GOLDEN_PATH, encoding="utf-8") as fh: + return json.load(fh) + + +# --------------------------------------------------------------------------- +# RemoteRef factories for common test scenarios +# --------------------------------------------------------------------------- + + +def _make_refs_for_code_reviewer() -> List[RemoteRef]: + """Refs for acme/code-reviewer: tags v2.0.0, v2.1.0, v3.0.0.""" + return [ + RemoteRef( + name="refs/tags/v2.0.0", + sha="aaaa000000000000000000000000000000000001", + ), + RemoteRef( + name="refs/tags/v2.1.0", + sha="abcd234567890abcdef1234567890abcdef12345", + ), + RemoteRef( + name="refs/tags/v3.0.0", + sha="bbbb000000000000000000000000000000000002", + ), + RemoteRef(name="refs/heads/main", sha="cccc000000000000000000000000000000000003"), + ] + + +def _make_refs_for_test_generator() -> List[RemoteRef]: + """Refs for acme/test-generator: tags v1.0.0, v1.0.3.""" + return [ + RemoteRef( + name="refs/tags/v1.0.0", + sha="1111000000000000000000000000000000000001", + ), + RemoteRef( + name="refs/tags/v1.0.3", + sha="def4567890abcdef1234567890abcdef12345678", + ), + RemoteRef(name="refs/heads/main", sha="dddd000000000000000000000000000000000004"), + ] + + +def _ref_side_effect(owner_repo: str) -> List[RemoteRef]: + """Return appropriate refs based on owner/repo slug.""" + mapping = { + "acme/code-reviewer": _make_refs_for_code_reviewer(), + "acme/test-generator": _make_refs_for_test_generator(), + } + if owner_repo in mapping: + return mapping[owner_repo] + # For unknown repos, return an empty list so tests fail deterministically + return [] + + +# --------------------------------------------------------------------------- +# mock_ref_resolver fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mock_ref_resolver(): + """Patch RefResolver.list_remote_refs with preset RemoteRef responses. + + Returns the MagicMock so tests can inspect call counts or override + the side_effect for specific scenarios. + + Usage in tests:: + + def test_something(mkt_repo_root, mock_ref_resolver): + # mock_ref_resolver.list_remote_refs is already patched + result = run_cli(["marketplace", "build"], cwd=mkt_repo_root) + assert result.returncode == 0 + """ + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_ref_side_effect, + ) as mock_obj: + yield mock_obj + + +@pytest.fixture() +def mock_ref_resolver_golden(): + """Patch RefResolver so code-reviewer resolves to v2.1.0 and + test-generator to v1.0.3 -- the exact SHAs in the golden fixture.""" + + def _golden_side_effect(owner_repo: str) -> List[RemoteRef]: + if owner_repo == "acme/code-reviewer": + return [ + RemoteRef( + name="refs/tags/v2.1.0", + sha="abcd234567890abcdef1234567890abcdef12345", + ), + ] + if owner_repo == "acme/test-generator": + return [ + RemoteRef( + name="refs/tags/v1.0.3", + sha="def4567890abcdef1234567890abcdef12345678", + ), + ] + return [] + + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_golden_side_effect, + ) as mock_obj: + yield mock_obj + + +# --------------------------------------------------------------------------- +# Live e2e fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def live_marketplace_repo() -> str: + """Return the value of APM_E2E_MARKETPLACE or skip the test. + + The value is validated to match the ``owner/repo`` format before + being returned. When the env var is absent the test is skipped with + a clear message so CI never fails due to a missing variable. + + Returns + ------- + str + The ``owner/repo`` value from APM_E2E_MARKETPLACE. + + Raises + ------ + pytest.skip.Exception + When APM_E2E_MARKETPLACE is not set. + """ + value = os.environ.get(_LIVE_ENV_VAR, "").strip() + if not value: + pytest.skip( + f"{_LIVE_ENV_VAR} is not set. " + "Set it to an owner/repo string (e.g. my-org/my-marketplace) " + "to run the live e2e tests locally." + ) + + parts = value.split("/") + if len(parts) != 2 or not parts[0] or not parts[1]: + pytest.skip( + f"{_LIVE_ENV_VAR}={value!r} is not in 'owner/repo' format. " + "Correct it and re-run." + ) + + return value + + +# --------------------------------------------------------------------------- +# run_cli helper +# --------------------------------------------------------------------------- + + +def run_cli( + args: List[str], + cwd: Optional[Path] = None, + env: Optional[dict] = None, + timeout: int = 60, +) -> subprocess.CompletedProcess: + """Invoke ``uv run apm `` via subprocess. + + The subprocess inherits a curated env containing PATH and HOME. + Additional env vars can be passed via *env*; they are merged on top. + + Parameters + ---------- + args: + CLI arguments after ``apm`` (e.g. ``["marketplace", "build"]``). + cwd: + Working directory for the subprocess. Defaults to the project root. + env: + Extra environment variables to add (or override) in the subprocess. + timeout: + Maximum seconds to wait before raising TimeoutExpired. + + Returns + ------- + subprocess.CompletedProcess + With stdout, stderr as str (text=True). + """ + base_env: dict = {} + + # Propagate essential host env vars + for key in ("PATH", "HOME", "USERPROFILE", "TMPDIR", "TEMP", "TMP"): + if key in os.environ: + base_env[key] = os.environ[key] + + # Propagate the live marketplace env var if set (so live tests work) + if _LIVE_ENV_VAR in os.environ: + base_env[_LIVE_ENV_VAR] = os.environ[_LIVE_ENV_VAR] + + # Propagate GITHUB_TOKEN / GH_TOKEN when present + for key in ("GITHUB_TOKEN", "GH_TOKEN"): + if key in os.environ: + base_env[key] = os.environ[key] + + # Caller overrides applied last + if env: + base_env.update(env) + + cmd = [sys.executable, "-m", "uv", "run", "apm"] + args + # Prefer the project-local uv wrapper if available + uv_bin = _project_uv_bin() + if uv_bin: + cmd = [uv_bin, "run", "apm"] + args + + return subprocess.run( + cmd, + cwd=str(cwd) if cwd else str(_PROJECT_ROOT), + capture_output=True, + text=True, + timeout=timeout, + env=base_env, + ) + + +def _project_uv_bin() -> Optional[str]: + """Return path to uv if it is on PATH, else None.""" + import shutil + + return shutil.which("uv") diff --git a/tests/integration/marketplace/test_build_integration.py b/tests/integration/marketplace/test_build_integration.py new file mode 100644 index 000000000..de31c37f8 --- /dev/null +++ b/tests/integration/marketplace/test_build_integration.py @@ -0,0 +1,287 @@ +"""Integration tests for ``apm marketplace build``. + +Strategy +-------- +These tests write a real marketplace.yml to a tmp directory, then invoke +``MarketplaceBuilder`` (or ``run_cli``) with ``RefResolver.list_remote_refs`` +patched so no real network calls are made. + +All assertions are against real file-system artefacts produced by the build +pipeline: marketplace.json existence, content, and exit codes. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +from apm_cli.marketplace.builder import BuildOptions, MarketplaceBuilder +from apm_cli.marketplace.ref_resolver import RemoteRef +from apm_cli.marketplace.yml_schema import load_marketplace_yml + +from .conftest import ( + GOLDEN_YML, + MINIMAL_YML, + _project_uv_bin, + run_cli, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_yml(tmp_path: Path, content: str) -> Path: + p = tmp_path / "marketplace.yml" + p.write_text(content, encoding="utf-8") + return p + + +def _read_json(tmp_path: Path) -> dict: + out = tmp_path / "marketplace.json" + return json.loads(out.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Build pipeline tests (library-level, not subprocess) +# --------------------------------------------------------------------------- + + +class TestBuildGoldenFile: + """The canonical input must produce byte-level output matching golden.json.""" + + def test_golden_content_matches( + self, tmp_path: Path, mock_ref_resolver_golden, golden_marketplace_json + ): + """Run the full build pipeline and compare output with golden.json.""" + _write_yml(tmp_path, GOLDEN_YML) + + opts = BuildOptions(dry_run=False) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml", options=opts) + report = builder.build() + + out_path = tmp_path / "marketplace.json" + assert out_path.exists(), "marketplace.json was not produced" + + actual = json.loads(out_path.read_text(encoding="utf-8")) + assert actual == golden_marketplace_json + + def test_key_order_follows_anthropic_schema( + self, tmp_path: Path, mock_ref_resolver_golden + ): + """Top-level keys must appear in Anthropic canonical order.""" + _write_yml(tmp_path, GOLDEN_YML) + + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + report = builder.build() + + out_path = tmp_path / "marketplace.json" + raw_text = out_path.read_text(encoding="utf-8") + data = json.loads(raw_text) + top_keys = list(data.keys()) + + # Anthropic schema order: name, description, version, owner, metadata, plugins + expected_order = ["name", "description", "version", "owner", "metadata", "plugins"] + assert top_keys == expected_order, ( + f"Expected key order {expected_order}, got {top_keys}" + ) + + def test_plugin_key_order(self, tmp_path: Path, mock_ref_resolver_golden): + """Each plugin must have keys in Anthropic order.""" + _write_yml(tmp_path, GOLDEN_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + builder.build() + + data = _read_json(tmp_path) + for plugin in data["plugins"]: + keys = [k for k in plugin.keys() if k != "description"] + # name must be first; tags before source; source last + assert keys[0] == "name" + assert "tags" in keys + assert keys[-1] == "source" + + def test_source_key_order(self, tmp_path: Path, mock_ref_resolver_golden): + """Each source block must follow: type, repository, (path), ref, commit.""" + _write_yml(tmp_path, GOLDEN_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + builder.build() + + data = _read_json(tmp_path) + # test-generator has subdir -> path must appear between repository and ref + tg = next(p for p in data["plugins"] if p["name"] == "test-generator") + src_keys = list(tg["source"].keys()) + assert src_keys == ["type", "repository", "path", "ref", "commit"] + + def test_no_apm_only_keys_in_output(self, tmp_path: Path, mock_ref_resolver_golden): + """APM-only fields must not appear in marketplace.json.""" + _write_yml(tmp_path, GOLDEN_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + builder.build() + + data = _read_json(tmp_path) + apm_only = {"subdir", "version_range", "tag_pattern", "include_prerelease"} + for plugin in data["plugins"]: + assert not apm_only.intersection(plugin.keys()), ( + f"APM-only key found in plugin {plugin['name']}: " + f"{apm_only.intersection(plugin.keys())}" + ) + if "source" in plugin: + assert not apm_only.intersection(plugin["source"].keys()) + + +class TestBuildHappyPath: + """Happy-path build scenarios.""" + + def test_produces_marketplace_json(self, tmp_path: Path, mock_ref_resolver): + """A successful build writes marketplace.json to disk.""" + _write_yml(tmp_path, MINIMAL_YML) + + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + report = builder.build() + + out = tmp_path / "marketplace.json" + assert out.exists() + assert report.dry_run is False + assert report.added_count == 2 # two packages, no prior file + + def test_resolved_packages_count(self, tmp_path: Path, mock_ref_resolver): + """Build report must list all resolved packages.""" + _write_yml(tmp_path, MINIMAL_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + report = builder.build() + assert len(report.resolved) == 2 + + def test_sha_in_output(self, tmp_path: Path, mock_ref_resolver): + """Each plugin in marketplace.json must have a 40-char commit SHA.""" + _write_yml(tmp_path, MINIMAL_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + builder.build() + + data = _read_json(tmp_path) + for plugin in data["plugins"]: + sha = plugin["source"]["commit"] + assert len(sha) == 40, f"Expected 40-char SHA, got {sha!r}" + assert all(c in "0123456789abcdef" for c in sha) + + def test_metadata_passed_through(self, tmp_path: Path, mock_ref_resolver): + """Metadata block is copied verbatim including camelCase keys.""" + _write_yml(tmp_path, MINIMAL_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + builder.build() + + data = _read_json(tmp_path) + assert "metadata" in data + assert data["metadata"]["pluginRoot"] == "plugins" + + def test_dry_run_does_not_write(self, tmp_path: Path, mock_ref_resolver): + """--dry-run must not create marketplace.json on disk.""" + _write_yml(tmp_path, MINIMAL_YML) + opts = BuildOptions(dry_run=True) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml", options=opts) + report = builder.build() + + out = tmp_path / "marketplace.json" + assert not out.exists() + assert report.dry_run is True + + def test_incremental_build_counts_unchanged( + self, tmp_path: Path, mock_ref_resolver + ): + """Second build with same refs reports unchanged packages, not added.""" + _write_yml(tmp_path, MINIMAL_YML) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + report1 = builder.build() + assert report1.added_count == 2 + + builder2 = MarketplaceBuilder(tmp_path / "marketplace.yml") + report2 = builder2.build() + assert report2.unchanged_count == 2 + assert report2.added_count == 0 + + +class TestBuildErrorPaths: + """Error handling at the library level.""" + + def test_schema_error_raises_marketplace_yml_error(self, tmp_path: Path): + """Malformed YAML raises MarketplaceYmlError.""" + from apm_cli.marketplace.errors import MarketplaceYmlError + + bad = tmp_path / "marketplace.yml" + bad.write_text("name: [\ninvalid: yaml\n", encoding="utf-8") + + builder = MarketplaceBuilder(bad) + with pytest.raises(MarketplaceYmlError): + builder.build() + + def test_missing_yml_raises(self, tmp_path: Path): + """Missing marketplace.yml raises MarketplaceYmlError.""" + from apm_cli.marketplace.errors import MarketplaceYmlError + + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + with pytest.raises(MarketplaceYmlError): + builder.build() + + def test_offline_with_no_cache_raises_offline_miss(self, tmp_path: Path): + """Offline build with empty cache raises OfflineMissError.""" + from apm_cli.marketplace.errors import OfflineMissError + + _write_yml(tmp_path, MINIMAL_YML) + opts = BuildOptions(offline=True) + builder = MarketplaceBuilder(tmp_path / "marketplace.yml", options=opts) + with pytest.raises((OfflineMissError, Exception)): + builder.build() + + def test_no_matching_version_raises(self, tmp_path: Path): + """Version range that matches no tag raises NoMatchingVersionError.""" + from apm_cli.marketplace.errors import NoMatchingVersionError + + _write_yml(tmp_path, MINIMAL_YML) + + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[], # no refs -> no matching version + ): + builder = MarketplaceBuilder(tmp_path / "marketplace.yml") + with pytest.raises((NoMatchingVersionError, Exception)): + builder.build() + + +# --------------------------------------------------------------------------- +# CLI subprocess tests +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not _project_uv_bin(), + reason="uv not found on PATH; skipping subprocess CLI tests", +) +class TestBuildCLI: + """Tests that invoke `apm marketplace build` via subprocess.""" + + def test_missing_yml_exits_1(self, tmp_path: Path): + """Missing marketplace.yml must exit 1.""" + result = run_cli(["marketplace", "build"], cwd=tmp_path) + assert result.returncode == 1 + combined = result.stdout + result.stderr + assert "marketplace.yml" in combined + + def test_schema_error_exits_2(self, tmp_path: Path): + """Malformed marketplace.yml must exit 2.""" + (tmp_path / "marketplace.yml").write_text( + "name: [\nbad: yaml\n", encoding="utf-8" + ) + result = run_cli(["marketplace", "build"], cwd=tmp_path) + assert result.returncode == 2 + + def test_dry_run_flag_present(self, tmp_path: Path, mock_ref_resolver): + """--dry-run must be accepted by the CLI (no crash).""" + _write_yml(tmp_path, MINIMAL_YML) + result = run_cli(["marketplace", "build", "--dry-run"], cwd=tmp_path) + # Without real network, build will fail resolving refs; exit != 0 is OK. + # Key check: exit code is not 2 (schema error) and no Python traceback. + assert result.returncode != 2 + assert "Traceback" not in result.stderr diff --git a/tests/integration/marketplace/test_check_integration.py b/tests/integration/marketplace/test_check_integration.py new file mode 100644 index 000000000..da564ff5a --- /dev/null +++ b/tests/integration/marketplace/test_check_integration.py @@ -0,0 +1,208 @@ +"""Integration tests for ``apm marketplace check``. + +Strategy +-------- +Tests write a real marketplace.yml and invoke the ``check`` command via +CliRunner with RefResolver.list_remote_refs patched. + +Scenarios covered: +- All entries resolvable -> exit 0. +- One entry unreachable -> exit 1 with error in output. +- Offline mode reports cached-only error per-row. +- Missing marketplace.yml -> exit 1. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import check +from apm_cli.marketplace.errors import GitLsRemoteError, OfflineMissError +from apm_cli.marketplace.ref_resolver import RemoteRef + + +# --------------------------------------------------------------------------- +# YAML fixtures +# --------------------------------------------------------------------------- + +_CHECK_YML = """\ +name: check-test +description: Marketplace for check tests +version: 1.0.0 +owner: + name: Test Org +packages: + - name: plugin-a + source: org/plugin-a + version: "^1.0.0" + tags: + - test + - name: plugin-b + source: org/plugin-b + ref: v2.0.0 + tags: + - test +""" + +_SINGLE_ENTRY_YML = """\ +name: single +description: Single-entry check +version: 1.0.0 +owner: + name: Test Org +packages: + - name: only-plugin + source: org/only-plugin + version: "^1.0.0" + tags: + - test +""" + + +def _refs_ok(owner_repo: str): + """All packages have satisfying refs.""" + return { + "org/plugin-a": [ + RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40), + RemoteRef(name="refs/tags/v1.2.0", sha="b" * 40), + ], + "org/plugin-b": [ + RemoteRef(name="refs/tags/v2.0.0", sha="c" * 40), + ], + "org/only-plugin": [ + RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40), + ], + }.get(owner_repo, []) + + +def _refs_plugin_a_missing(owner_repo: str): + """plugin-a returns no refs (simulating a remote error via empty list).""" + if owner_repo == "org/plugin-a": + return [] + return _refs_ok(owner_repo) + + +def _run_check(tmp_path: Path, yml_content, extra_args=(), side_effect=None): + if side_effect is None: + side_effect = _refs_ok + + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(yml_content, encoding="utf-8") + + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=side_effect, + ): + result = runner.invoke(check, list(extra_args), catch_exceptions=False) + return result + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestCheckAllReachable: + """When all entries are resolvable, check exits 0.""" + + def test_exit_code_zero_all_ok(self, tmp_path: Path): + result = _run_check(tmp_path, _CHECK_YML, side_effect=_refs_ok) + assert result.exit_code == 0 + + def test_success_message_in_output(self, tmp_path: Path): + result = _run_check(tmp_path, _CHECK_YML, side_effect=_refs_ok) + combined = result.output + assert "OK" in combined or "[+]" in combined + + def test_package_names_appear(self, tmp_path: Path): + result = _run_check(tmp_path, _CHECK_YML, side_effect=_refs_ok) + assert "plugin-a" in result.output + assert "plugin-b" in result.output + + +class TestCheckOneUnreachable: + """When one entry is unreachable, check exits 1.""" + + def _side_effect_raise(self, owner_repo: str): + if owner_repo == "org/plugin-a": + raise GitLsRemoteError( + package="plugin-a", + summary="git ls-remote failed for 'org/plugin-a'", + hint="Check network.", + ) + return _refs_ok(owner_repo) + + def test_exit_code_one_on_unreachable(self, tmp_path: Path): + result = _run_check( + tmp_path, _CHECK_YML, side_effect=self._side_effect_raise + ) + assert result.exit_code == 1 + + def test_error_summary_in_output(self, tmp_path: Path): + result = _run_check( + tmp_path, _CHECK_YML, side_effect=self._side_effect_raise + ) + combined = result.output + # Either [x] marker or "issues" text should appear + assert "[x]" in combined or "issue" in combined.lower() or "error" in combined.lower() + + def test_error_entry_named_in_output(self, tmp_path: Path): + result = _run_check( + tmp_path, _CHECK_YML, side_effect=self._side_effect_raise + ) + assert "plugin-a" in result.output + + def test_other_entries_still_reported(self, tmp_path: Path): + """Entries that succeed must still appear in the output.""" + result = _run_check( + tmp_path, _CHECK_YML, side_effect=self._side_effect_raise + ) + assert "plugin-b" in result.output + + +class TestCheckOffline: + """--offline flag reports cached-only status.""" + + @staticmethod + def _raise_offline_miss(owner_repo: str): + """Simulate RefResolver in offline mode with an empty cache.""" + raise OfflineMissError(package="", remote=owner_repo) + + def test_offline_mode_exits_nonzero_on_empty_cache(self, tmp_path: Path): + """Offline with empty cache must fail (OfflineMissError per entry).""" + result = _run_check( + tmp_path, + _CHECK_YML, + extra_args=["--offline"], + side_effect=self._raise_offline_miss, + ) + # No cache -> all entries fail; exit 1 expected + assert result.exit_code == 1 + assert "Traceback" not in result.output + + def test_offline_mode_does_not_crash(self, tmp_path: Path): + """Offline flag must not produce a Python traceback.""" + result = _run_check( + tmp_path, + _CHECK_YML, + extra_args=["--offline"], + side_effect=self._raise_offline_miss, + ) + assert "Traceback" not in result.output + + +class TestCheckMissingYml: + """check without marketplace.yml exits 1.""" + + def test_missing_yml_exits_1(self, tmp_path: Path): + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + result = runner.invoke(check, [], catch_exceptions=False) + assert result.exit_code == 1 diff --git a/tests/integration/marketplace/test_doctor_integration.py b/tests/integration/marketplace/test_doctor_integration.py new file mode 100644 index 000000000..39a299e85 --- /dev/null +++ b/tests/integration/marketplace/test_doctor_integration.py @@ -0,0 +1,231 @@ +"""Integration tests for ``apm marketplace doctor``. + +Strategy +-------- +Tests invoke the ``doctor`` command via CliRunner and mock the subprocess +calls that probe git and network availability. This keeps the tests +hermetic without requiring a real network or specific git version. + +Scenarios covered: +- All checks pass when git is on PATH and network is reachable. +- Exit 1 when git is not on PATH. +- Auth check is informational: GITHUB_TOKEN set -> note in output. +- marketplace.yml check reports its status (informational). +- No Python tracebacks under any mocked scenario. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import doctor + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fake_git_ok(*args, **kwargs): + """Fake subprocess.run that mimics a healthy git environment.""" + cmd = list(args[0]) if args else list(kwargs.get("args", [])) + m = MagicMock() + m.returncode = 0 + if "git" in cmd and "--version" in cmd: + m.stdout = "git version 2.42.0" + m.stderr = "" + elif "git" in cmd and "ls-remote" in cmd: + # Network check (github.com/git/git.git HEAD) + m.stdout = "abc123\tHEAD\n" + m.stderr = "" + else: + m.stdout = "" + m.stderr = "" + return m + + +def _fake_git_not_found(*args, **kwargs): + """Fake subprocess.run that raises FileNotFoundError (git not on PATH).""" + raise FileNotFoundError("git not found") + + +def _fake_git_version_ok_network_fail(*args, **kwargs): + """git --version succeeds; git ls-remote fails.""" + cmd = list(args[0]) if args else list(kwargs.get("args", [])) + m = MagicMock() + if "git" in cmd and "--version" in cmd: + m.returncode = 0 + m.stdout = "git version 2.42.0" + m.stderr = "" + elif "git" in cmd and "ls-remote" in cmd: + m.returncode = 128 + m.stdout = "" + m.stderr = "fatal: unable to access 'https://github.com/git/git.git/': timed out" + else: + m.returncode = 0 + m.stdout = "" + m.stderr = "" + return m + + +def _run_doctor(extra_args=(), env_overrides=None, yml_content=None, tmp_path=None): + """Invoke doctor via CliRunner with subprocess.run patched.""" + runner = CliRunner() + env = os.environ.copy() + # Strip tokens so auth check is deterministic by default + env.pop("GITHUB_TOKEN", None) + env.pop("GH_TOKEN", None) + if env_overrides: + env.update(env_overrides) + + if tmp_path is not None and yml_content is not None: + (tmp_path / "marketplace.yml").write_text(yml_content, encoding="utf-8") + + with runner.isolated_filesystem() as cwd: + if tmp_path is not None and yml_content is not None: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch("subprocess.run", side_effect=_fake_git_ok): + with patch.dict(os.environ, env, clear=True): + result = runner.invoke(doctor, list(extra_args), catch_exceptions=False) + return result + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestDoctorAllPass: + """When git and network are available, doctor exits 0.""" + + def test_exit_code_zero(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_ok): + with patch.dict(os.environ, {"GITHUB_TOKEN": ""}, clear=False): + result = runner.invoke(doctor, [], catch_exceptions=False) + assert result.exit_code == 0 + + def test_git_check_appears_in_output(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_ok): + result = runner.invoke(doctor, [], catch_exceptions=False) + assert "git" in result.output + + def test_network_check_appears_in_output(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_ok): + result = runner.invoke(doctor, [], catch_exceptions=False) + combined = result.output + assert "network" in combined.lower() or "reachable" in combined.lower() + + def test_no_traceback(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_ok): + result = runner.invoke(doctor, [], catch_exceptions=False) + assert "Traceback" not in result.output + + +class TestDoctorGitNotFound: + """When git is not on PATH, doctor exits 1.""" + + def test_exit_code_one(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_not_found): + result = runner.invoke(doctor, [], catch_exceptions=False) + assert result.exit_code == 1 + + def test_git_error_in_output(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_not_found): + result = runner.invoke(doctor, [], catch_exceptions=False) + combined = result.output + assert "git" in combined.lower() + + def test_no_traceback_on_git_not_found(self): + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_not_found): + result = runner.invoke(doctor, [], catch_exceptions=False) + assert "Traceback" not in result.output + + +class TestDoctorAuthCheck: + """Auth check is informational and never fails the command.""" + + def test_token_detected_note_appears(self): + """When GITHUB_TOKEN is set, doctor notes it (does not print the value).""" + runner = CliRunner() + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_ok): + with patch.dict(os.environ, {"GITHUB_TOKEN": "ghp_test"}, clear=False): + result = runner.invoke(doctor, [], catch_exceptions=False) + combined = result.output + # Token presence should be noted + assert "Token detected" in combined or "token" in combined.lower() + # The token value must never appear in output + assert "ghp_test" not in combined + + def test_no_token_note_appears(self): + """When no token is set, doctor notes unauthenticated rate limits.""" + runner = CliRunner() + # Remove all token env vars + clean_env = { + k: v for k, v in os.environ.items() + if k not in ("GITHUB_TOKEN", "GH_TOKEN") + } + with runner.isolated_filesystem(): + with patch("subprocess.run", side_effect=_fake_git_ok): + with patch.dict(os.environ, clean_env, clear=True): + result = runner.invoke(doctor, [], catch_exceptions=False) + combined = result.output + assert "auth" in combined.lower() or "token" in combined.lower() + + +class TestDoctorMarketplaceYml: + """marketplace.yml check is informational.""" + + def test_yml_present_and_valid_noted(self, tmp_path: Path): + yml_content = """\ +name: doc-test +description: Doctor test +version: 1.0.0 +owner: + name: Test Org +packages: + - name: pkg + source: org/pkg + version: "^1.0.0" + tags: + - test +""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(yml_content, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch("subprocess.run", side_effect=_fake_git_ok): + result = runner.invoke(doctor, [], catch_exceptions=False) + # Should mention marketplace.yml in the output table + assert "marketplace.yml" in result.output + + def test_yml_absent_does_not_fail(self, tmp_path: Path): + """Missing marketplace.yml is informational, not a critical failure.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + with patch("subprocess.run", side_effect=_fake_git_ok): + result = runner.invoke(doctor, [], catch_exceptions=False) + # Critical checks (git, network) pass -> exit 0 + assert result.exit_code == 0 + assert "Traceback" not in result.output diff --git a/tests/integration/marketplace/test_init_integration.py b/tests/integration/marketplace/test_init_integration.py new file mode 100644 index 000000000..4d8d58387 --- /dev/null +++ b/tests/integration/marketplace/test_init_integration.py @@ -0,0 +1,143 @@ +"""Integration tests for ``apm marketplace init``. + +Strategy +-------- +These tests exercise the real init command by invoking the Click +application through the CliRunner. They verify scaffold creation, +idempotency guard, --force overwrite, gitignore warning, and that +the produced file parses via yml_schema. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import init +from apm_cli.marketplace.init_template import render_marketplace_yml_template +from apm_cli.marketplace.yml_schema import load_marketplace_yml + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _run_init(tmp_path: Path, extra_args=(), catch_exceptions=True): + """Invoke 'apm marketplace init' via CliRunner in *tmp_path*.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + # Write files into the isolated filesystem's CWD + result = runner.invoke( + init, + list(extra_args), + catch_exceptions=catch_exceptions, + ) + return result + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestInitScaffold: + """Verify scaffold creation behaviour.""" + + def test_creates_marketplace_yml(self, tmp_path: Path): + """init must write marketplace.yml in the current directory.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + result = runner.invoke(init, [], catch_exceptions=False) + yml_path = Path(cwd) / "marketplace.yml" + assert yml_path.exists(), "marketplace.yml was not created" + assert result.exit_code == 0 + + def test_template_content_is_valid_yml(self, tmp_path: Path): + """The scaffold content must parse without errors.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + runner.invoke(init, [], catch_exceptions=False) + yml_path = Path(cwd) / "marketplace.yml" + # load_marketplace_yml must not raise + parsed = load_marketplace_yml(yml_path) + assert parsed.name == "my-marketplace" + + def test_success_message_in_output(self, tmp_path: Path): + """init must print a success message.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + result = runner.invoke(init, [], catch_exceptions=False) + combined = result.output + assert "marketplace.yml" in combined + + def test_verbose_shows_path(self, tmp_path: Path): + """--verbose must show the output path.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + result = runner.invoke(init, ["--verbose"], catch_exceptions=False) + assert "marketplace.yml" in result.output or "Path" in result.output + + def test_template_contains_packages_example(self, tmp_path: Path): + """Scaffold must contain at least one example package entry.""" + template = render_marketplace_yml_template() + assert "packages:" in template + assert "source:" in template + + +class TestInitIdempotency: + """Running init twice without --force must fail.""" + + def test_second_run_without_force_exits_1(self, tmp_path: Path): + """Second init in same directory must exit 1.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + runner.invoke(init, [], catch_exceptions=False) + result = runner.invoke(init, [], catch_exceptions=False) + assert result.exit_code == 1 + assert "already exists" in result.output or "--force" in result.output + + def test_force_overwrites_existing(self, tmp_path: Path): + """--force must overwrite an existing marketplace.yml.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + # First run + runner.invoke(init, [], catch_exceptions=False) + yml_path = Path(cwd) / "marketplace.yml" + yml_path.write_text("corrupted: true\n", encoding="utf-8") + # Force overwrite + result = runner.invoke(init, ["--force"], catch_exceptions=False) + content = yml_path.read_text(encoding="utf-8") + assert result.exit_code == 0 + # The scaffold must have replaced the corrupted content + assert "my-marketplace" in content + + +class TestInitGitignoreWarning: + """Warn when marketplace.json is gitignored.""" + + def test_warning_when_marketplace_json_gitignored(self, tmp_path: Path): + """A .gitignore entry for marketplace.json must produce a warning.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + gitignore = Path(cwd) / ".gitignore" + gitignore.write_text("marketplace.json\n", encoding="utf-8") + result = runner.invoke(init, [], catch_exceptions=False) + combined = result.output + assert "gitignore" in combined.lower() or "ignore" in combined.lower() + + def test_no_warning_without_gitignore(self, tmp_path: Path): + """No gitignore warning when .gitignore does not exist.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + # Ensure no .gitignore exists + gi = Path(cwd) / ".gitignore" + if gi.exists(): + gi.unlink() + result = runner.invoke(init, [], catch_exceptions=False) + combined = result.output + # Must succeed; no error about gitignore + assert result.exit_code == 0 + assert "gitignore" not in combined.lower() diff --git a/tests/integration/marketplace/test_live_e2e.py b/tests/integration/marketplace/test_live_e2e.py new file mode 100644 index 000000000..51fe3dc66 --- /dev/null +++ b/tests/integration/marketplace/test_live_e2e.py @@ -0,0 +1,177 @@ +"""Live end-to-end tests for ``apm marketplace`` commands. + +These tests require the ``APM_E2E_MARKETPLACE`` environment variable to be +set to a valid ``owner/repo`` string pointing to a real GitHub marketplace +repository. When the variable is absent all tests in this module are +skipped automatically. + +Example usage (maintainer only): + + export APM_E2E_MARKETPLACE=my-org/my-marketplace-repo + uv run pytest tests/integration/marketplace/test_live_e2e.py -v + +IMPORTANT: No publish live test is included. The ``publish`` command writes +to third-party repositories and is intrinsically destructive. Publish is +covered at the unit and integration tiers only (with mocked services). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from .conftest import run_cli + + +# --------------------------------------------------------------------------- +# Shared live YAML template +# --------------------------------------------------------------------------- + +def _live_yml(owner_repo: str) -> str: + """Minimal marketplace.yml that references the live repo. + + Uses ^1.0.0 as the version range so that any v1.x.y tag on the + remote satisfies the range. If the remote has no v1.x tags the + test will exit with an appropriate error (not a skip). + """ + return f"""\ +name: live-test-marketplace +description: Minimal live test marketplace +version: 0.1.0 +owner: + name: Live Test Runner +packages: + - name: live-plugin + source: {owner_repo} + version: "^1.0.0" + tags: + - live +""" + + +# --------------------------------------------------------------------------- +# Tests (all depend on live_marketplace_repo fixture) +# --------------------------------------------------------------------------- + + +class TestLiveBuild: + """Live build test: resolves real tags and writes marketplace.json.""" + + def test_live_build_succeeds(self, live_marketplace_repo, tmp_path): + """Build with a real remote must exit 0 and produce marketplace.json.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "build"], cwd=tmp_path, timeout=120) + + assert result.returncode == 0, ( + f"build exited {result.returncode}\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + + out_path = tmp_path / "marketplace.json" + assert out_path.exists(), "marketplace.json was not produced" + + def test_live_build_resolves_sha(self, live_marketplace_repo, tmp_path): + """All plugins in the produced marketplace.json must have a 40-char SHA.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "build"], cwd=tmp_path, timeout=120) + if result.returncode != 0: + pytest.skip( + f"Build failed (possible: no v1.x tags on {live_marketplace_repo}). " + f"stdout={result.stdout}" + ) + + out_path = tmp_path / "marketplace.json" + data = json.loads(out_path.read_text(encoding="utf-8")) + + sha_re = re.compile(r"^[0-9a-f]{40}$") + for plugin in data.get("plugins", []): + sha = plugin.get("source", {}).get("commit", "") + assert sha_re.match(sha), ( + f"Plugin {plugin.get('name')} has invalid SHA: {sha!r}" + ) + + +class TestLiveOutdated: + """Live outdated test: reports upgrades in correct format.""" + + def test_live_outdated_exits_zero(self, live_marketplace_repo, tmp_path): + """outdated always exits 0 (informational command).""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "outdated"], cwd=tmp_path, timeout=120) + + assert result.returncode == 0, ( + f"outdated exited {result.returncode}\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + + def test_live_outdated_output_contains_package_name( + self, live_marketplace_repo, tmp_path + ): + """Output must contain the package name from the yml.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "outdated"], cwd=tmp_path, timeout=120) + + assert "live-plugin" in result.stdout or "live-plugin" in result.stderr, ( + "Expected package name 'live-plugin' to appear in outdated output" + ) + + +class TestLiveCheck: + """Live check test: all entries must be reachable.""" + + def test_live_check_exits_zero(self, live_marketplace_repo, tmp_path): + """check must exit 0 when all entries resolve against the live remote.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "check"], cwd=tmp_path, timeout=120) + + assert result.returncode in (0, 1), ( + f"check exited {result.returncode} (unexpected)\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + # If exit 1, it should be because no v1.x tag satisfies the range, + # not because the remote is unreachable. + if result.returncode == 1: + combined = result.stdout + result.stderr + # Verify it is a resolution error, not a network error + assert "git ls-remote failed" not in combined, ( + "check failed due to network error, not a resolution mismatch" + ) + + +class TestLiveDoctor: + """Live doctor test: git + network checks should pass in CI with network.""" + + def test_live_doctor_exits_zero(self, live_marketplace_repo, tmp_path): + """doctor must exit 0 when git is available and github.com is reachable.""" + # Place a valid marketplace.yml so the yml check is informational-pass + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "doctor"], cwd=tmp_path, timeout=30) + + assert result.returncode == 0, ( + f"doctor exited {result.returncode}\n" + f"stdout={result.stdout}\nstderr={result.stderr}" + ) + + def test_live_doctor_mentions_git(self, live_marketplace_repo, tmp_path): + """doctor output must mention the git check.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text(_live_yml(live_marketplace_repo), encoding="utf-8") + + result = run_cli(["marketplace", "doctor"], cwd=tmp_path, timeout=30) + + assert "git" in (result.stdout + result.stderr).lower() diff --git a/tests/integration/marketplace/test_outdated_integration.py b/tests/integration/marketplace/test_outdated_integration.py new file mode 100644 index 000000000..a17e546a0 --- /dev/null +++ b/tests/integration/marketplace/test_outdated_integration.py @@ -0,0 +1,228 @@ +"""Integration tests for ``apm marketplace outdated``. + +Strategy +-------- +Tests write a real marketplace.yml and invoke the ``outdated`` command +via CliRunner with RefResolver.list_remote_refs patched. + +The test matrix covers: +- version-range entries with upgrades available in and out of range. +- Ref-pinned entries are skipped with a note. +- Exit code is always 0 (outdated is informational). +- Offline mode. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import outdated +from apm_cli.marketplace.ref_resolver import RemoteRef + + +# --------------------------------------------------------------------------- +# Fixtures / YAML content +# --------------------------------------------------------------------------- + +_OUTDATED_YML = """\ +name: outdated-test +description: Marketplace for outdated tests +version: 1.0.0 +owner: + name: Test Org +packages: + - name: alpha + source: org/alpha + version: "^1.0.0" + tags: + - test + - name: beta + source: org/beta + version: "^2.0.0" + tags: + - test + - name: pinned + source: org/pinned + ref: v1.0.0 + tags: + - test +""" + + +def _refs_alpha(): + """alpha: v1.0.0 (current range), v1.1.0 (upgrade in range), v2.0.0 (major).""" + return [ + RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40), + RemoteRef(name="refs/tags/v1.1.0", sha="b" * 40), + RemoteRef(name="refs/tags/v2.0.0", sha="c" * 40), + ] + + +def _refs_beta(): + """beta: v2.0.0 only -- no newer version available.""" + return [ + RemoteRef(name="refs/tags/v2.0.0", sha="d" * 40), + ] + + +def _refs_pinned(): + """pinned: one tag (the pinned one); should be skipped by outdated.""" + return [ + RemoteRef(name="refs/tags/v1.0.0", sha="e" * 40), + ] + + +def _side_effect(owner_repo: str): + return { + "org/alpha": _refs_alpha(), + "org/beta": _refs_beta(), + "org/pinned": _refs_pinned(), + }.get(owner_repo, []) + + +def _run_outdated(tmp_path: Path, extra_args=(), yml_content=_OUTDATED_YML): + runner = CliRunner() + yml = tmp_path / "marketplace.yml" + yml.write_text(yml_content, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + # Symlink the written marketplace.yml into the isolated FS CWD + import os + import shutil + cwd = Path(os.getcwd()) + shutil.copy(str(yml), str(cwd / "marketplace.yml")) + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, list(extra_args), catch_exceptions=False) + return result + + +def _run_outdated_cwd(extra_args=()): + """Run outdated in a preconfigured isolated FS (caller sets up CWD).""" + runner = CliRunner() + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, list(extra_args), catch_exceptions=False) + return result + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestOutdatedVersionRanges: + """Verify upgrade classification for version-range entries.""" + + def test_exit_code_always_zero(self, tmp_path: Path): + """outdated must exit 0 regardless of upgrade availability.""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(_OUTDATED_YML, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, [], catch_exceptions=False) + assert result.exit_code == 0 + + def test_package_names_appear_in_output(self, tmp_path: Path): + """Output must mention every package entry.""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(_OUTDATED_YML, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, [], catch_exceptions=False) + combined = result.output + assert "alpha" in combined + assert "beta" in combined + assert "pinned" in combined + + def test_pinned_entry_is_skipped(self, tmp_path: Path): + """Ref-pinned entries must be reported as skipped (not as upgrades).""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(_OUTDATED_YML, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, [], catch_exceptions=False) + combined = result.output + # Skipped entries should show [i] or "Pinned" in the table + assert "pinned" in combined + assert ("[i]" in combined or "Pinned" in combined or "skipped" in combined.lower()) + + def test_upgrade_available_in_range(self, tmp_path: Path): + """alpha ^1.0.0 has v1.1.0 available in range; output must show [!] or similar.""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(_OUTDATED_YML, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, [], catch_exceptions=False) + combined = result.output + # alpha should show an upgrade marker; v1.1.0 should appear + assert "v1.1.0" in combined + + def test_major_outside_range_is_noted(self, tmp_path: Path): + """alpha has v2.0.0 which is outside ^1.0.0; it should appear in output.""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(_OUTDATED_YML, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + with patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=_side_effect, + ): + result = runner.invoke(outdated, [], catch_exceptions=False) + combined = result.output + # v2.0.0 is the latest overall; it should appear in the overall-latest column + assert "v2.0.0" in combined + + +class TestOutdatedMissingYml: + """outdated without marketplace.yml exits 1.""" + + def test_missing_yml_exits_1(self, tmp_path: Path): + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=str(tmp_path)): + result = runner.invoke(outdated, [], catch_exceptions=False) + assert result.exit_code == 1 + + +class TestOutdatedOffline: + """--offline flag is accepted and does not crash.""" + + def test_offline_flag_exits_zero_or_one_not_two(self, tmp_path: Path): + """--offline with empty cache must not crash with exit 2.""" + runner = CliRunner() + (tmp_path / "marketplace.yml").write_text(_OUTDATED_YML, encoding="utf-8") + with runner.isolated_filesystem(temp_dir=str(tmp_path)) as cwd: + import shutil + shutil.copy(str(tmp_path / "marketplace.yml"), cwd + "/marketplace.yml") + result = runner.invoke(outdated, ["--offline"], catch_exceptions=False) + # Offline miss is reported per-row; exit code should still be 0 or 1, not 2 + assert result.exit_code != 2 + assert "Traceback" not in result.output diff --git a/tests/integration/marketplace/test_publish_integration.py b/tests/integration/marketplace/test_publish_integration.py new file mode 100644 index 000000000..ed974d6b5 --- /dev/null +++ b/tests/integration/marketplace/test_publish_integration.py @@ -0,0 +1,411 @@ +"""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 +import os +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest +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.MarketplacePublisher.plan", + return_value=plan, + ), + patch( + "apm_cli.commands.marketplace.MarketplacePublisher.execute", + return_value=results, + ), + patch( + "apm_cli.commands.marketplace.PrIntegrator.check_available", + return_value=(mock_pr_available, "gh available"), + ), + patch( + "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + side_effect=pr_results, + ), + patch( + "apm_cli.commands.marketplace._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.MarketplacePublisher.plan", + return_value=plan, + ), + patch( + "apm_cli.commands.marketplace.MarketplacePublisher.execute", + execute_mock, + ), + patch( + "apm_cli.commands.marketplace.PrIntegrator.check_available", + return_value=(True, "ok"), + ), + patch( + "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + return_value=_make_pr_result("consumer-org/service-a", PrState.SKIPPED), + ), + patch( + "apm_cli.commands.marketplace._is_interactive", + return_value=False, + ), + ): + result = runner.invoke( + publish, ["--yes", "--dry-run"], catch_exceptions=False + ) + + # 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.MarketplacePublisher.plan", + return_value=plan, + ), + patch( + "apm_cli.commands.marketplace.MarketplacePublisher.execute", + return_value=[ + _make_target_result("consumer-org/service-a", PublishOutcome.UPDATED) + ], + ), + patch( + "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + open_or_update_mock, + ), + patch( + "apm_cli.commands.marketplace._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/unit/commands/conftest.py b/tests/unit/commands/conftest.py new file mode 100644 index 000000000..1c535af1a --- /dev/null +++ b/tests/unit/commands/conftest.py @@ -0,0 +1,46 @@ +"""Shared fixtures for ``tests/unit/commands/``.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +# Cache the *real* is_enabled so we can delegate non-marketplace flags. +from apm_cli.core.experimental import is_enabled as _real_is_enabled + + +def _marketplace_enabled_is_enabled(name: str) -> bool: + """Stub that forces ``marketplace_authoring`` to True.""" + if name == "marketplace_authoring": + return True + return _real_is_enabled(name) + + +@pytest.fixture(autouse=True) +def _enable_marketplace_flag(request): + """Pre-enable the ``marketplace_authoring`` experimental flag. + + The marketplace group callback guards execution behind this flag. + All *existing* marketplace tests need the flag enabled so they + exercise the subcommand logic rather than hitting the guard. + + Only applies to test modules whose name contains "marketplace" + (excluding ``test_marketplace_gating`` which tests disabled state). + + Patches ``is_enabled`` at the source module so it survives any + config-cache isolation performed by individual tests. + """ + module_name = request.module.__name__ + is_marketplace_test = "marketplace" in module_name + is_gating_test = "gating" in module_name + + if is_marketplace_test and not is_gating_test: + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=_marketplace_enabled_is_enabled, + ): + yield + else: + yield diff --git a/tests/unit/commands/test_marketplace_build.py b/tests/unit/commands/test_marketplace_build.py new file mode 100644 index 000000000..84536b86e --- /dev/null +++ b/tests/unit/commands/test_marketplace_build.py @@ -0,0 +1,429 @@ +"""Tests for ``apm marketplace build`` subcommand.""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.marketplace.builder import BuildOptions, BuildReport, ResolvedPackage +from apm_cli.marketplace.errors import ( + BuildError, + GitLsRemoteError, + HeadNotAllowedError, + MarketplaceYmlError, + NoMatchingVersionError, + OfflineMissError, + RefNotFoundError, +) + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +_SHA_A = "a" * 40 +_SHA_B = "b" * 40 + +_BASIC_YML = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pkg-alpha + source: acme-org/pkg-alpha + version: "^1.0.0" + description: Alpha package + tags: [testing] + - name: pkg-beta + source: acme-org/pkg-beta + version: "~2.0.0" + tags: [utility] +""") + + +def _make_report( + resolved=None, errors=(), dry_run=False, + unchanged=0, added=2, updated=0, removed=0, +): + """Build a fake BuildReport.""" + if resolved is None: + resolved = ( + ResolvedPackage( + name="pkg-alpha", + source_repo="acme-org/pkg-alpha", + subdir=None, + ref="v1.2.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=("testing",), + is_prerelease=False, + ), + ResolvedPackage( + name="pkg-beta", + source_repo="acme-org/pkg-beta", + subdir="src/plugin", + ref="v2.0.1", + sha=_SHA_B, + requested_version="~2.0.0", + tags=("utility",), + is_prerelease=False, + ), + ) + return BuildReport( + resolved=resolved, + errors=errors, + warnings=(), + unchanged_count=unchanged, + added_count=added, + updated_count=updated, + removed_count=removed, + output_path=Path("marketplace.json"), + dry_run=dry_run, + ) + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def yml_cwd(tmp_path, monkeypatch): + """Set CWD to tmp_path and write a valid marketplace.yml.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestBuildHappyPath: + """build command -- success scenarios.""" + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_basic_build_success(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 0 + assert "Built marketplace.json" in result.output + assert "2 packages" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_build_table_contains_package_names(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 0 + assert "pkg-alpha" in result.output + assert "pkg-beta" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_build_table_contains_version_refs(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + result = runner.invoke(marketplace, ["build"]) + assert "v1.2.0" in result.output + assert "v2.0.1" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_build_table_shows_sha_prefix(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + result = runner.invoke(marketplace, ["build"]) + assert _SHA_A[:8] in result.output + assert _SHA_B[:8] in result.output + + +# --------------------------------------------------------------------------- +# Dry-run +# --------------------------------------------------------------------------- + + +class TestBuildDryRun: + """build --dry-run scenarios.""" + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_dry_run_message(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report(dry_run=True) + + result = runner.invoke(marketplace, ["build", "--dry-run"]) + assert result.exit_code == 0 + assert "Dry run" in result.output + assert "not written" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_dry_run_no_built_message(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report(dry_run=True) + + result = runner.invoke(marketplace, ["build", "--dry-run"]) + assert "Built marketplace.json" not in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_dry_run_passes_option_to_builder(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report(dry_run=True) + + runner.invoke(marketplace, ["build", "--dry-run"]) + opts = MockBuilder.call_args[1].get("options") or MockBuilder.call_args[0][1] + assert opts.dry_run is True + + +# --------------------------------------------------------------------------- +# Flag forwarding +# --------------------------------------------------------------------------- + + +class TestBuildFlags: + """Verify CLI flags are forwarded to BuildOptions.""" + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_offline_flag(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + runner.invoke(marketplace, ["build", "--offline"]) + opts = MockBuilder.call_args[1].get("options") or MockBuilder.call_args[0][1] + assert opts.offline is True + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_include_prerelease_flag(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + runner.invoke(marketplace, ["build", "--include-prerelease"]) + opts = MockBuilder.call_args[1].get("options") or MockBuilder.call_args[0][1] + assert opts.include_prerelease is True + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_verbose_flag(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report() + + result = runner.invoke(marketplace, ["build", "--verbose"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Missing / bad marketplace.yml +# --------------------------------------------------------------------------- + + +class TestBuildMissingYml: + """build command -- no marketplace.yml.""" + + def test_missing_yml_exits_1(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "No marketplace.yml found" in result.output + + def test_missing_yml_suggests_init(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["build"]) + assert "init" in result.output + + +class TestBuildSchemaError: + """build command -- invalid marketplace.yml.""" + + def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("not: valid\n", encoding="utf-8") + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 2 + assert "schema error" in result.output.lower() or "required" in result.output.lower() + + def test_bad_yaml_syntax_exits_2(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(":\n - !!invalid", encoding="utf-8") + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 2 + + +# --------------------------------------------------------------------------- +# Build errors +# --------------------------------------------------------------------------- + + +class TestBuildErrors: + """build command -- BuildError subclass handling.""" + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_no_matching_version_error(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = NoMatchingVersionError( + "pkg-alpha", "^1.0.0" + ) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "pkg-alpha" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_ref_not_found_error(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = RefNotFoundError( + "pkg-alpha", "v99.0.0", "acme-org/pkg-alpha" + ) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "not found" in result.output.lower() + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_git_ls_remote_error(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = GitLsRemoteError( + package="pkg-alpha", + summary="Authentication failed", + hint="Check your GITHUB_TOKEN", + ) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "Authentication failed" in result.output + assert "GITHUB_TOKEN" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_offline_miss_error(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = OfflineMissError( + package="pkg-alpha", remote="acme-org/pkg-alpha" + ) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "offline" in result.output.lower() or "cache" in result.output.lower() + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_head_not_allowed_error(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = HeadNotAllowedError( + package="pkg-alpha", ref="main" + ) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "main" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_generic_build_error(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = BuildError( + "Something unexpected", package="pkg-alpha" + ) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Empty packages +# --------------------------------------------------------------------------- + + +class TestBuildEdgeCases: + """Edge cases for the build command.""" + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_empty_packages_list(self, MockBuilder, runner, yml_cwd): + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report(resolved=(), added=0) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 0 + assert "0 packages" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_single_package(self, MockBuilder, runner, yml_cwd): + single = ( + ResolvedPackage( + name="only-one", + source_repo="acme-org/only-one", + subdir=None, + ref="v3.0.0", + sha=_SHA_A, + requested_version="^3.0.0", + tags=(), + is_prerelease=False, + ), + ) + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report(resolved=single, added=1) + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 0 + assert "only-one" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_prerelease_package(self, MockBuilder, runner, yml_cwd): + pre = ( + ResolvedPackage( + name="beta-pkg", + source_repo="acme-org/beta-pkg", + subdir=None, + ref="v2.0.0-rc.1", + sha=_SHA_A, + requested_version="^2.0.0", + tags=(), + is_prerelease=True, + ), + ) + mock_inst = MockBuilder.return_value + mock_inst.build.return_value = _make_report(resolved=pre, added=1) + + result = runner.invoke(marketplace, ["build", "--include-prerelease"]) + assert result.exit_code == 0 + assert "v2.0.0-rc.1" in result.output + + +# --------------------------------------------------------------------------- +# Verbose traceback (L3) +# --------------------------------------------------------------------------- + + +class TestBuildVerboseTraceback: + """build --verbose -- traceback on unexpected failure.""" + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_verbose_shows_traceback_on_unexpected_error( + self, MockBuilder, runner, yml_cwd + ): + """When --verbose is passed and build raises an unexpected error, + stderr should contain the full traceback.""" + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = RuntimeError("unexpected internal failure") + + result = runner.invoke(marketplace, ["build", "--verbose"]) + assert result.exit_code == 1 + assert "Traceback" in result.output + assert "unexpected internal failure" in result.output + + @patch("apm_cli.commands.marketplace.MarketplaceBuilder") + def test_no_traceback_without_verbose(self, MockBuilder, runner, yml_cwd): + """Without --verbose the traceback is suppressed.""" + mock_inst = MockBuilder.return_value + mock_inst.build.side_effect = RuntimeError("unexpected internal failure") + + result = runner.invoke(marketplace, ["build"]) + assert result.exit_code == 1 + assert "Traceback" not in result.output + assert "Build failed" in result.output diff --git a/tests/unit/commands/test_marketplace_check.py b/tests/unit/commands/test_marketplace_check.py new file mode 100644 index 000000000..a04bc6f76 --- /dev/null +++ b/tests/unit/commands/test_marketplace_check.py @@ -0,0 +1,423 @@ +"""Tests for ``apm marketplace check`` subcommand.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.marketplace.errors import ( + GitLsRemoteError, + MarketplaceYmlError, + OfflineMissError, +) +from apm_cli.marketplace.ref_resolver import RemoteRef +from apm_cli.marketplace.yml_schema import ( + MarketplaceOwner, + MarketplaceYml, + PackageEntry, +) + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +_SHA_A = "a" * 40 +_SHA_B = "b" * 40 + +_BASIC_YML = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pkg-alpha + source: acme-org/pkg-alpha + version: "^1.0.0" + tags: [testing] + - name: pkg-beta + source: acme-org/pkg-beta + version: "~2.0.0" + tags: [utility] +""") + +_YML_WITH_REF = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pinned-pkg + source: acme-org/pinned-pkg + ref: v1.0.0 +""") + +_YML_SINGLE = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: solo + source: acme-org/solo + version: "^1.0.0" +""") + +_REFS_GOOD = [ + RemoteRef(name="refs/tags/v1.0.0", sha=_SHA_A), + RemoteRef(name="refs/tags/v1.1.0", sha=_SHA_B), +] + +_REFS_BETA_GOOD = [ + RemoteRef(name="refs/tags/v2.0.0", sha=_SHA_A), + RemoteRef(name="refs/tags/v2.0.1", sha=_SHA_B), +] + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def yml_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# Happy path -- all entries OK +# --------------------------------------------------------------------------- + + +class TestCheckAllOK: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_all_entries_pass(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 0 + assert "All 2 entries OK" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_shows_package_names(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert "pkg-alpha" in result.output + assert "pkg-beta" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_success_icon_shown(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert "[+]" in result.output + + +# --------------------------------------------------------------------------- +# Entry with explicit ref +# --------------------------------------------------------------------------- + + +class TestCheckExplicitRef: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_ref_found(self, MockResolver, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.return_value = [ + RemoteRef(name="refs/tags/v1.0.0", sha=_SHA_A), + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 0 + assert "pinned-pkg" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_ref_not_found(self, MockResolver, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "200") + (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") + mock_inst = MockResolver.return_value + # Return tags that don't include v1.0.0 + mock_inst.list_remote_refs.return_value = [ + RemoteRef(name="refs/tags/v2.0.0", sha=_SHA_B), + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 1 + assert "1 entries have issues" in result.output + + +# --------------------------------------------------------------------------- +# Failed entries +# --------------------------------------------------------------------------- + + +class TestCheckFailures: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_one_failure_exits_1(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + # First package OK, second fails + mock_inst.list_remote_refs.side_effect = [ + _REFS_GOOD, + GitLsRemoteError( + package="pkg-beta", summary="Auth failed", hint="Check token" + ), + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 1 + assert "1 entries have issues" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_all_failures_exits_1(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = GitLsRemoteError( + package="", summary="Network down", hint="Check connection" + ) + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 1 + assert "2 entries have issues" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_failure_icon_shown(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = GitLsRemoteError( + package="", summary="Fail", hint="" + ) + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert "[x]" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_no_matching_version(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + # Return tags that don't match the version range + mock_inst.list_remote_refs.side_effect = [ + [RemoteRef(name="refs/tags/v0.1.0", sha=_SHA_A)], + _REFS_BETA_GOOD, + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 1 + assert "1 entries have issues" in result.output + + +# --------------------------------------------------------------------------- +# Missing yml / schema error +# --------------------------------------------------------------------------- + + +class TestCheckMissingYml: + def test_missing_yml_exits_1(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 1 + + def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("invalid: thing\n", encoding="utf-8") + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 2 + + +# --------------------------------------------------------------------------- +# Offline mode +# --------------------------------------------------------------------------- + + +class TestCheckOffline: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_offline_label_shown(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = OfflineMissError( + package="", remote="acme-org/pkg-alpha" + ) + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check", "--offline"]) + assert "Offline mode" in result.output or "offline" in result.output.lower() + MockResolver.assert_called_once_with(offline=True) + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_offline_cache_miss_fails_entry(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = OfflineMissError( + package="", remote="acme-org/pkg-alpha" + ) + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check", "--offline"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Verbose +# --------------------------------------------------------------------------- + + +class TestCheckVerbose: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_verbose_no_crash(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check", "--verbose"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Resolver cleanup +# --------------------------------------------------------------------------- + + +class TestCheckResolverCleanup: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_resolver_close_called(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] + mock_inst.close = MagicMock() + + runner.invoke(marketplace, ["check"]) + mock_inst.close.assert_called_once() + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_resolver_close_on_failure(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = GitLsRemoteError( + package="", summary="Fail", hint="" + ) + mock_inst.close = MagicMock() + + runner.invoke(marketplace, ["check"]) + mock_inst.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestCheckEdgeCases: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_single_entry_all_ok(self, MockResolver, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_YML_SINGLE, encoding="utf-8") + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.return_value = _REFS_GOOD + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 0 + assert "All 1 entries OK" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_generic_exception_handled(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = RuntimeError("Unexpected") + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert result.exit_code == 1 + assert "Unexpected" in result.output + + +# --------------------------------------------------------------------------- +# Duplicate package name detection +# --------------------------------------------------------------------------- + + +class TestCheckDuplicateNames: + """Defence-in-depth duplicate name check in the check command.""" + + @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.load_marketplace_yml") + def test_duplicate_names_warned( + self, mock_load, MockResolver, runner, tmp_path, monkeypatch, + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8") + + # Return a MarketplaceYml with duplicate package names + mock_load.return_value = MarketplaceYml( + name="test", + description="Test", + version="1.0.0", + owner=MarketplaceOwner(name="Owner"), + packages=( + PackageEntry( + name="learning", source="acme/repo", subdir="general", + version="^1.0.0", + ), + PackageEntry( + name="learning", source="acme/repo", subdir="special", + version="^1.0.0", + ), + ), + ) + + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.return_value = [ + RemoteRef(name="refs/tags/v1.0.0", sha=_SHA_A), + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert "Duplicate package name 'learning'" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.load_marketplace_yml") + def test_no_warning_when_unique( + self, mock_load, MockResolver, runner, tmp_path, monkeypatch, + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8") + + mock_load.return_value = MarketplaceYml( + name="test", + description="Test", + version="1.0.0", + owner=MarketplaceOwner(name="Owner"), + packages=( + PackageEntry( + name="alpha", source="acme/alpha", version="^1.0.0", + ), + PackageEntry( + name="beta", source="acme/beta", version="^1.0.0", + ), + ), + ) + + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.return_value = [ + RemoteRef(name="refs/tags/v1.0.0", sha=_SHA_A), + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["check"]) + assert "Duplicate" not in result.output diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py new file mode 100644 index 000000000..02074cf2e --- /dev/null +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -0,0 +1,616 @@ +"""Tests for ``apm marketplace doctor`` subcommand.""" + +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.marketplace.yml_schema import ( + MarketplaceOwner, + MarketplaceYml, + PackageEntry, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_BASIC_YML = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: solo + source: acme-org/solo + version: "^1.0.0" +""") + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _make_run_result(returncode=0, stdout="", stderr=""): + """Build a fake subprocess.CompletedProcess.""" + return subprocess.CompletedProcess( + args=["git"], returncode=returncode, stdout=stdout, stderr=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") +_GIT_CRED_OK = _make_run_result(0, stdout="") + + +# --------------------------------------------------------------------------- +# All checks pass +# --------------------------------------------------------------------------- + + +class TestDoctorAllPass: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_all_pass_exit_0(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_TOKEN", "test-token") + (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") + + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0, stdout="abc123\tHEAD"), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 0 + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_git_version_shown(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, stdout="abc123\tHEAD"), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "git version" in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_network_reachable_shown(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), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "reachable" in result.output.lower() + + +# --------------------------------------------------------------------------- +# Check 1: git on PATH +# --------------------------------------------------------------------------- + + +class TestDoctorGitCheck: + @patch("apm_cli.commands.marketplace.subprocess.run") + 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"]) + assert result.exit_code == 1 + assert "not found" in result.output.lower() + + @patch("apm_cli.commands.marketplace.subprocess.run") + 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"]) + assert result.exit_code == 1 + assert "timed out" in result.output.lower() + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_git_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mock_run.side_effect = [ + _make_run_result(returncode=1, stderr="error"), + _make_run_result(0), # network check may still run + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Check 2: network +# --------------------------------------------------------------------------- + + +class TestDoctorNetworkCheck: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_network_failure_exits_1(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(128, stderr="fatal: could not resolve host"), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_network_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"), + subprocess.TimeoutExpired(cmd="git", timeout=5), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + assert "timed out" in result.output.lower() + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_network_auth_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(128, stderr="fatal: authentication failed"), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Check 3: auth token +# --------------------------------------------------------------------------- + + +class TestDoctorAuthCheck: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_github_token_detected(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "Token detected" in result.output + # Must NOT print the actual token + assert "ghp_test123" not in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_gh_token_detected(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "gho_test456") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "Token detected" in result.output + assert "gho_test456" not in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_no_token_informational(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["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.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), + _GIT_CRED_OK, + _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.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), + _GIT_CRED_OK, + 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.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), + _GIT_CRED_OK, + _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.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), + _GIT_CRED_OK, + 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.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), + _GIT_CRED_OK, + 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.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), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "gh cli" in result.output.lower() + + +# --------------------------------------------------------------------------- +# Check 5: marketplace.yml +# --------------------------------------------------------------------------- + + +class TestDoctorYmlCheck: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_yml_present_and_valid(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 0 + assert "valid" in result.output.lower() or "found" in result.output.lower() + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_yml_present_but_invalid(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("bad: true\n", encoding="utf-8") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + # yml check is informational; critical checks still pass + assert result.exit_code == 0 + assert "error" in result.output.lower() + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_yml_absent(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), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 0 + assert "No marketplace.yml" in result.output + + +# --------------------------------------------------------------------------- +# Exit code logic (check 4 never blocks) +# --------------------------------------------------------------------------- + + +class TestDoctorExitCodes: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_yml_invalid_does_not_cause_exit_1(self, mock_run, runner, tmp_path, monkeypatch): + """Check 5 is informational; invalid yml alone should not exit 1.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("bad: x\n", encoding="utf-8") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 0 + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_git_fail_plus_valid_yml_exits_1(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") + mock_run.side_effect = FileNotFoundError("git not found") + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Verbose +# --------------------------------------------------------------------------- + + +class TestDoctorVerbose: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_verbose_no_crash(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), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor", "--verbose"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Table rendering +# --------------------------------------------------------------------------- + + +class TestDoctorTable: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_table_has_check_column(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), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + # Table should mention the check names + assert "git" in result.output.lower() + assert "network" in result.output.lower() + assert "auth" in result.output.lower() + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_info_icon_for_auth(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "[i]" in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_pass_icon_for_git(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), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert "[+]" in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + 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"]) + assert "[x]" in result.output + + +# --------------------------------------------------------------------------- +# Edge: subprocess general exception +# --------------------------------------------------------------------------- + + +class TestDoctorEdgeCases: + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_general_exception_in_git_check(self, mock_run, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + mock_run.side_effect = OSError("Permission denied") + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + assert "Permission denied" in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_git_ok_network_file_not_found(self, mock_run, runner, tmp_path, monkeypatch): + """When git works but network check raises FileNotFoundError.""" + monkeypatch.chdir(tmp_path) + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + FileNotFoundError("git not found"), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Check 6: duplicate package names +# --------------------------------------------------------------------------- + + +class TestDoctorDuplicateNames: + """Defence-in-depth duplicate name check in the doctor command.""" + + @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.load_marketplace_yml") + def test_duplicate_names_flagged( + self, mock_load, mock_run, runner, tmp_path, monkeypatch, + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + mock_load.return_value = MarketplaceYml( + name="test", + description="Test", + version="1.0.0", + owner=MarketplaceOwner(name="Owner"), + packages=( + PackageEntry( + name="learning", source="acme/repo", subdir="general", + version="^1.0.0", + ), + PackageEntry( + name="learning", source="acme/repo", subdir="special", + version="^1.0.0", + ), + ), + ) + + result = runner.invoke(marketplace, ["doctor"]) + assert "duplicate" in result.output.lower() + assert "learning" in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.load_marketplace_yml") + def test_no_duplicate_names_shows_pass( + self, mock_load, mock_run, runner, tmp_path, monkeypatch, + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8") + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + mock_load.return_value = MarketplaceYml( + name="test", + description="Test", + version="1.0.0", + owner=MarketplaceOwner(name="Owner"), + packages=( + PackageEntry( + name="alpha", source="acme/alpha", version="^1.0.0", + ), + PackageEntry( + name="beta", source="acme/beta", version="^1.0.0", + ), + ), + ) + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 0 + assert "No duplicate package names" in result.output + + @patch("apm_cli.commands.marketplace.subprocess.run") + def test_no_duplicate_check_when_yml_absent( + self, mock_run, runner, tmp_path, monkeypatch, + ): + """When marketplace.yml is missing, duplicate check is skipped.""" + monkeypatch.chdir(tmp_path) + mock_run.side_effect = [ + _make_run_result(0, stdout="git version 2.40.0"), + _make_run_result(0), + _GIT_CRED_OK, + _GH_OK, + ] + + result = runner.invoke(marketplace, ["doctor"]) + assert result.exit_code == 0 + assert "duplicate" not in result.output.lower() diff --git a/tests/unit/commands/test_marketplace_gating.py b/tests/unit/commands/test_marketplace_gating.py new file mode 100644 index 000000000..6705f9e2f --- /dev/null +++ b/tests/unit/commands/test_marketplace_gating.py @@ -0,0 +1,271 @@ +"""Tests for marketplace experimental flag gating. + +Verifies: + - ``marketplace_authoring`` flag is registered in the ``FLAGS`` registry + - Consumer commands (add, list, browse, update, remove, validate) work + WITHOUT the flag enabled + - Authoring commands (init, build, check, outdated, doctor, publish, package) + are blocked when the flag is disabled, with an enablement message + - Authoring commands proceed when the flag is enabled + +Note: The directory-level conftest patches ``is_enabled`` to return True +for ``marketplace_authoring`` (so existing marketplace subcommand tests pass). +Tests here that need the flag *disabled* wrap their assertions in an +explicit ``patch`` context manager that overrides the conftest mock. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from apm_cli.core.experimental import is_enabled as _real_is_enabled + +import pytest +from click.testing import CliRunner + + +# --------------------------------------------------------------------------- +# Flag registration (uses the real FLAGS dict -- unaffected by is_enabled mock) +# --------------------------------------------------------------------------- + + +class TestMarketplaceFlagRegistration: + """Verify the marketplace_authoring flag exists with correct metadata.""" + + def test_marketplace_flag_in_registry(self) -> None: + """marketplace_authoring is a registered ExperimentalFlag.""" + from apm_cli.core.experimental import FLAGS + + assert "marketplace_authoring" in FLAGS + + def test_flag_default_is_false(self) -> None: + """Flag ships disabled by default.""" + from apm_cli.core.experimental import FLAGS + + flag = FLAGS["marketplace_authoring"] + assert flag.default is False + + def test_flag_name_matches_key(self) -> None: + """Registry key matches the flag's .name attribute.""" + from apm_cli.core.experimental import FLAGS + + flag = FLAGS["marketplace_authoring"] + assert flag.name == "marketplace_authoring" + + def test_flag_has_hint(self) -> None: + """Flag provides a post-enable hint.""" + from apm_cli.core.experimental import FLAGS + + flag = FLAGS["marketplace_authoring"] + assert flag.hint is not None + assert "marketplace" in flag.hint.lower() + + def test_flag_description_mentions_authoring(self) -> None: + """Flag description is scoped to authoring commands only.""" + from apm_cli.core.experimental import FLAGS + + flag = FLAGS["marketplace_authoring"] + assert "authoring" in flag.description.lower() + + +# --------------------------------------------------------------------------- +# Consumer commands: always available (no flag required) +# --------------------------------------------------------------------------- + + +class TestConsumerCommandsUngated: + """Consumer commands must work without marketplace_authoring enabled.""" + + @pytest.mark.parametrize("subcmd", ["add", "list", "browse", "update", "remove", "validate"]) + def test_consumer_command_reachable_when_flag_disabled(self, subcmd: str) -> None: + """Consumer subcommands are not blocked by the authoring flag.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, [subcmd, "--help"]) + + # --help should succeed (exit 0) and NOT show the experimental + # gating message -- the command is reachable. + assert result.exit_code == 0 + assert "experimental" not in result.output.lower() + + def test_marketplace_help_works_when_flag_disabled(self) -> None: + """``marketplace --help`` shows consumer section without the flag.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, ["--help"]) + + assert result.exit_code == 0 + assert "Consumer commands" in result.output + + def test_marketplace_help_hides_authoring_when_flag_disabled(self) -> None: + """``marketplace --help`` omits authoring section when flag is off.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, ["--help"]) + + assert result.exit_code == 0 + assert "Authoring commands" not in result.output + + @pytest.mark.parametrize("subcmd", ["init", "build", "check", "outdated", "doctor", "publish", "package"]) + def test_authoring_commands_hidden_from_help_when_flag_disabled(self, subcmd: str) -> None: + """Individual authoring command names are absent from --help when flag is off.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, ["--help"]) + + assert result.exit_code == 0 + # Each authoring command name should not appear as a listed subcommand + # (it may appear in the group description; check the commands section) + lines = result.output.split("\n") + command_lines = [ + line for line in lines + if line.strip().startswith(subcmd) + ] + assert not command_lines, ( + f"Authoring command '{subcmd}' should be hidden from --help " + f"when flag is disabled, but found: {command_lines}" + ) + + +# --------------------------------------------------------------------------- +# Authoring commands: blocked without the flag +# --------------------------------------------------------------------------- + + +class TestAuthoringCommandsGated: + """Authoring commands must be blocked when the flag is disabled.""" + + @pytest.mark.parametrize("subcmd", ["init", "build", "check", "outdated", "doctor", "publish"]) + def test_authoring_command_blocked_when_disabled(self, subcmd: str) -> None: + """Authoring subcommand exits with enablement hint when flag off.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, [subcmd]) + + assert result.exit_code != 0 + assert "experimental" in result.output.lower() + assert "apm experimental enable marketplace-authoring" in result.output + + def test_package_subgroup_blocked_when_disabled(self) -> None: + """``marketplace package`` exits with enablement hint when flag off.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, ["package", "add", "x/y"]) + + assert result.exit_code != 0 + assert "experimental" in result.output.lower() + assert "apm experimental enable marketplace-authoring" in result.output + + @pytest.mark.parametrize("subcmd", ["init", "build", "check", "outdated", "doctor", "publish"]) + def test_authoring_guard_message_includes_learn_more(self, subcmd: str) -> None: + """Guard message includes 'apm experimental list' for discoverability.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: False, + ): + result = runner.invoke(marketplace, [subcmd]) + + assert "apm experimental list" in result.output + + +# --------------------------------------------------------------------------- +# Authoring commands: accessible when the flag IS enabled +# --------------------------------------------------------------------------- + + +class TestAuthoringCommandsEnabled: + """Authoring commands proceed normally when the flag is enabled.""" + + @pytest.fixture(autouse=True) + def _enable_flag(self): + """Enable marketplace_authoring for this class's tests.""" + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=lambda name: True if name == "marketplace_authoring" else _real_is_enabled(name), + ): + yield + + @pytest.mark.parametrize("subcmd", ["init", "build", "check", "outdated", "doctor", "publish"]) + def test_authoring_command_help_reachable_when_enabled(self, subcmd: str) -> None: + """Authoring subcommand --help works when flag is enabled.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + result = runner.invoke(marketplace, [subcmd, "--help"]) + + assert result.exit_code == 0 + assert "experimental" not in result.output.lower() + + def test_package_subgroup_help_reachable_when_enabled(self) -> None: + """``marketplace package --help`` works when flag is enabled.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + result = runner.invoke(marketplace, ["package", "--help"]) + + assert result.exit_code == 0 + assert "experimental" not in result.output.lower() + + def test_marketplace_help_shows_both_sections_when_enabled(self) -> None: + """``marketplace --help`` shows Consumer and Authoring sections when flag on.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + result = runner.invoke(marketplace, ["--help"]) + + assert result.exit_code == 0 + assert "Consumer commands" in result.output + assert "Authoring commands" in result.output + + @pytest.mark.parametrize("subcmd", ["init", "build", "check", "outdated", "doctor", "publish", "package"]) + def test_authoring_commands_listed_in_help_when_enabled(self, subcmd: str) -> None: + """Authoring command names appear in --help when flag is on.""" + from apm_cli.commands.marketplace import marketplace + + runner = CliRunner() + result = runner.invoke(marketplace, ["--help"]) + + assert result.exit_code == 0 + lines = result.output.split("\n") + command_lines = [ + line for line in lines + if line.strip().startswith(subcmd) + ] + assert command_lines, ( + f"Authoring command '{subcmd}' should be visible in --help " + f"when flag is enabled" + ) diff --git a/tests/unit/commands/test_marketplace_init.py b/tests/unit/commands/test_marketplace_init.py new file mode 100644 index 000000000..773c5048c --- /dev/null +++ b/tests/unit/commands/test_marketplace_init.py @@ -0,0 +1,250 @@ +"""Tests for ``apm marketplace init`` subcommand.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.marketplace.yml_schema import load_marketplace_yml + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runner(): + return CliRunner() + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestInitHappyPath: + def test_creates_marketplace_yml(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert (tmp_path / "marketplace.yml").exists() + + def test_success_message(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert "Created marketplace.yml" in result.output + + def test_next_steps_shown(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert "apm marketplace build" in result.output + + def test_template_roundtrips_through_schema(self, runner, tmp_path, monkeypatch): + """The scaffolded file must parse without errors.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + yml = load_marketplace_yml(tmp_path / "marketplace.yml") + assert yml.name == "my-marketplace" + assert yml.version == "0.1.0" + assert yml.owner.name == "acme-org" + assert len(yml.packages) >= 1 + + +# --------------------------------------------------------------------------- +# File-already-exists guard +# --------------------------------------------------------------------------- + + +class TestInitExistsGuard: + def test_error_when_file_exists(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + existing = tmp_path / "marketplace.yml" + existing.write_text("name: keep-me\n", encoding="utf-8") + + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 1 + assert "already exists" in result.output + + def test_file_unchanged_without_force(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + existing = tmp_path / "marketplace.yml" + original_content = "name: keep-me\n" + existing.write_text(original_content, encoding="utf-8") + + runner.invoke(marketplace, ["init"]) + assert existing.read_text(encoding="utf-8") == original_content + + def test_force_overwrites(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + existing = tmp_path / "marketplace.yml" + existing.write_text("name: stale-sentinel\n", encoding="utf-8") + + result = runner.invoke(marketplace, ["init", "--force"]) + assert result.exit_code == 0 + new_content = existing.read_text(encoding="utf-8") + assert "my-marketplace" in new_content + assert "stale-sentinel" not in new_content + + +# --------------------------------------------------------------------------- +# .gitignore staleness check +# --------------------------------------------------------------------------- + + +class TestInitGitignoreCheck: + def test_warns_when_gitignore_ignores_marketplace_json( + self, runner, tmp_path, monkeypatch, + ): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitignore").write_text( + "marketplace.json\n", encoding="utf-8", + ) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert ".gitignore ignores marketplace.json" in result.output + + def test_warns_for_glob_pattern(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitignore").write_text( + "**/marketplace.json\n", encoding="utf-8", + ) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert ".gitignore ignores marketplace.json" in result.output + + def test_warns_for_rooted_pattern(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitignore").write_text( + "/marketplace.json\n", encoding="utf-8", + ) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert ".gitignore ignores marketplace.json" in result.output + + def test_no_warning_for_commented_line(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitignore").write_text( + "# marketplace.json\n", encoding="utf-8", + ) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert ".gitignore ignores marketplace.json" not in result.output + + def test_no_gitignore_check_suppresses_warning( + self, runner, tmp_path, monkeypatch, + ): + monkeypatch.chdir(tmp_path) + (tmp_path / ".gitignore").write_text( + "marketplace.json\n", encoding="utf-8", + ) + result = runner.invoke(marketplace, ["init", "--no-gitignore-check"]) + assert result.exit_code == 0 + assert ".gitignore ignores marketplace.json" not in result.output + + def test_no_warning_without_gitignore(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + assert ".gitignore" not in result.output + + +# --------------------------------------------------------------------------- +# --verbose flag +# --------------------------------------------------------------------------- + + +class TestInitVerbose: + def test_verbose_shows_path(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init", "--verbose"]) + assert result.exit_code == 0 + assert "Path:" in result.output + + +# --------------------------------------------------------------------------- +# Content checks +# --------------------------------------------------------------------------- + + +class TestInitContentSafety: + def test_template_contains_acme_org(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(marketplace, ["init"]) + content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") + assert "acme-org" in content + + def test_template_has_no_epam_references(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(marketplace, ["init"]) + content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8").lower() + assert "epam" not in content + assert "bookstore" not in content + assert "agent-forge" not in content + + def test_template_is_pure_ascii(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(marketplace, ["init"]) + content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") + content.encode("ascii") # raises UnicodeEncodeError if non-ASCII + + +# --------------------------------------------------------------------------- +# --name / --owner flags +# --------------------------------------------------------------------------- + + +class TestInitNameOwnerFlags: + def test_custom_name(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init", "--name", "cool-tools"]) + assert result.exit_code == 0 + yml = load_marketplace_yml(tmp_path / "marketplace.yml") + assert yml.name == "cool-tools" + + def test_custom_owner(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init", "--owner", "my-org"]) + assert result.exit_code == 0 + yml = load_marketplace_yml(tmp_path / "marketplace.yml") + assert yml.owner.name == "my-org" + + def test_custom_name_and_owner(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + marketplace, ["init", "--name", "my-mkt", "--owner", "my-team"], + ) + assert result.exit_code == 0 + yml = load_marketplace_yml(tmp_path / "marketplace.yml") + assert yml.name == "my-mkt" + assert yml.owner.name == "my-team" + content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") + assert "my-team" in content + # The default acme-org should not appear when owner is overridden. + assert "acme-org" not in content + + def test_defaults_without_flags(self, runner, tmp_path, monkeypatch): + """Without --name/--owner the defaults are used.""" + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["init"]) + assert result.exit_code == 0 + yml = load_marketplace_yml(tmp_path / "marketplace.yml") + assert yml.name == "my-marketplace" + assert yml.owner.name == "acme-org" + + def test_custom_values_are_pure_ascii(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + marketplace, ["init", "--name", "ascii-only", "--owner", "plain-org"], + ) + assert result.exit_code == 0 + content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") + content.encode("ascii") # raises UnicodeEncodeError if non-ASCII diff --git a/tests/unit/commands/test_marketplace_outdated.py b/tests/unit/commands/test_marketplace_outdated.py new file mode 100644 index 000000000..b1476f7e6 --- /dev/null +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -0,0 +1,403 @@ +"""Tests for ``apm marketplace outdated`` subcommand.""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.marketplace.errors import ( + BuildError, + GitLsRemoteError, + MarketplaceYmlError, + OfflineMissError, +) +from apm_cli.marketplace.ref_resolver import RemoteRef + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +_SHA_A = "a" * 40 +_SHA_B = "b" * 40 +_SHA_C = "c" * 40 +_SHA_D = "d" * 40 + +_BASIC_YML = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pkg-alpha + source: acme-org/pkg-alpha + version: "^1.0.0" + tags: [testing] + - name: pkg-beta + source: acme-org/pkg-beta + version: "~2.0.0" + tags: [utility] +""") + +_YML_WITH_REF = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pinned-pkg + source: acme-org/pinned-pkg + ref: v1.0.0 +""") + +_YML_SINGLE = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: solo + source: acme-org/solo + version: "^1.0.0" +""") + +_REFS_ALPHA = [ + RemoteRef(name="refs/tags/v1.0.0", sha=_SHA_A), + RemoteRef(name="refs/tags/v1.1.0", sha=_SHA_B), + RemoteRef(name="refs/tags/v1.2.0", sha=_SHA_C), + RemoteRef(name="refs/tags/v2.0.0", sha=_SHA_D), +] + +_REFS_BETA = [ + RemoteRef(name="refs/tags/v2.0.0", sha=_SHA_A), + RemoteRef(name="refs/tags/v2.0.1", sha=_SHA_B), +] + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def yml_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestOutdatedHappyPath: + """outdated -- basic success.""" + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_shows_package_names(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + # Packages are outdated (no marketplace.json) so exit code is 1 + assert result.exit_code == 1 + assert "pkg-alpha" in result.output + assert "pkg-beta" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_shows_latest_in_range(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + # Packages are outdated (no marketplace.json) so exit code is 1 + assert result.exit_code == 1 + # v1.2.0 is highest in ^1.0.0 range + assert "v1.2.0" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_shows_latest_overall(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + # v2.0.0 is highest overall for alpha + assert "v2.0.0" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): + """Exit code 1 when packages are outdated (CI-friendly).""" + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 1 + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_with_marketplace_json_present(self, MockResolver, runner, yml_cwd): + """Current versions read from marketplace.json.""" + mkt = { + "plugins": [ + {"name": "pkg-alpha", "source": {"ref": "v1.0.0", "commit": _SHA_A}}, + {"name": "pkg-beta", "source": {"ref": "v2.0.0", "commit": _SHA_A}}, + ] + } + (yml_cwd / "marketplace.json").write_text( + json.dumps(mkt), encoding="utf-8" + ) + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + # Packages are outdated (current != latest_in_range) so exit code is 1 + assert result.exit_code == 1 + assert "v1.0.0" in result.output # current for alpha + + +# --------------------------------------------------------------------------- +# Ref-pinned entries (skipped) +# --------------------------------------------------------------------------- + + +class TestOutdatedRefPinned: + """Entries with explicit ref: are skipped.""" + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_ref_pinned_shows_skip_note(self, MockResolver, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") + mock_inst = MockResolver.return_value + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 0 + assert "pinned-pkg" in result.output + assert "ref" in result.output.lower() or "skipped" in result.output.lower() + + +# --------------------------------------------------------------------------- +# Missing yml / schema error +# --------------------------------------------------------------------------- + + +class TestOutdatedMissingYml: + def test_missing_yml_exits_1(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 1 + + def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text("bad: true\n", encoding="utf-8") + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 2 + + +# --------------------------------------------------------------------------- +# Offline flag +# --------------------------------------------------------------------------- + + +class TestOutdatedOffline: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_offline_passed_to_resolver(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = OfflineMissError( + package="", remote="acme-org/pkg-alpha" + ) + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated", "--offline"]) + assert result.exit_code == 0 # outdated is informational + MockResolver.assert_called_once_with(offline=True) + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestOutdatedErrors: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_resolver_error_shows_in_table(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = GitLsRemoteError( + package="", summary="Auth failed", hint="Check token" + ) + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 0 + assert "pkg-alpha" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_no_matching_tags(self, MockResolver, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "marketplace.yml").write_text(_YML_SINGLE, encoding="utf-8") + mock_inst = MockResolver.return_value + # Return tags that don't match the pattern + mock_inst.list_remote_refs.return_value = [ + RemoteRef(name="refs/tags/release-1.0", sha=_SHA_A), + ] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 0 + assert "solo" in result.output + + +# --------------------------------------------------------------------------- +# Verbose +# --------------------------------------------------------------------------- + + +class TestOutdatedVerbose: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_verbose_shows_upgradable_count(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated", "--verbose"]) + # Packages are outdated so exit code is 1 + assert result.exit_code == 1 + assert "upgradable" in result.output.lower() + + +# --------------------------------------------------------------------------- +# Status symbols +# --------------------------------------------------------------------------- + + +class TestOutdatedStatusSymbols: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_up_to_date_status(self, MockResolver, runner, yml_cwd): + """When current == latest-in-range, status is [+].""" + mkt = { + "plugins": [ + {"name": "pkg-alpha", "source": {"ref": "v1.2.0", "commit": _SHA_C}}, + {"name": "pkg-beta", "source": {"ref": "v2.0.1", "commit": _SHA_B}}, + ] + } + (yml_cwd / "marketplace.json").write_text( + json.dumps(mkt), encoding="utf-8" + ) + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 0 + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_major_upgrade_status(self, MockResolver, runner, yml_cwd): + """When latest-overall differs from latest-in-range, status is [*].""" + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + # Packages are outdated (no marketplace.json) so exit code is 1 + assert result.exit_code == 1 + # pkg-alpha has v2.0.0 outside ^1.0.0 range + assert "[*]" in result.output + + +# --------------------------------------------------------------------------- +# Resolver cleanup +# --------------------------------------------------------------------------- + + +class TestOutdatedResolverCleanup: + @patch("apm_cli.commands.marketplace.RefResolver") + def test_resolver_close_called(self, MockResolver, runner, yml_cwd): + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + runner.invoke(marketplace, ["outdated"]) + mock_inst.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Summary line (UX5) +# --------------------------------------------------------------------------- + + +class TestOutdatedSummaryLine: + """outdated -- summary line and CI exit code.""" + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_summary_line_when_outdated(self, MockResolver, runner, yml_cwd): + """Summary line reports outdated and up-to-date counts.""" + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert "package(s) can be updated" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_exit_code_zero_when_up_to_date(self, MockResolver, runner, yml_cwd): + """Exit code 0 when all packages are up to date.""" + mkt = { + "plugins": [ + {"name": "pkg-alpha", "source": {"ref": "v1.2.0", "commit": _SHA_C}}, + {"name": "pkg-beta", "source": {"ref": "v2.0.1", "commit": _SHA_B}}, + ] + } + (yml_cwd / "marketplace.json").write_text( + json.dumps(mkt), encoding="utf-8" + ) + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 0 + assert "All packages are up to date" in result.output + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): + """Exit code 1 when packages are outdated (CI-friendly).""" + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 1 + + @patch("apm_cli.commands.marketplace.RefResolver") + def test_summary_counts_up_to_date(self, MockResolver, runner, yml_cwd): + """Up-to-date count reflects packages at latest in range.""" + mkt = { + "plugins": [ + {"name": "pkg-alpha", "source": {"ref": "v1.2.0", "commit": _SHA_C}}, + {"name": "pkg-beta", "source": {"ref": "v2.0.1", "commit": _SHA_B}}, + ] + } + (yml_cwd / "marketplace.json").write_text( + json.dumps(mkt), encoding="utf-8" + ) + mock_inst = MockResolver.return_value + mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] + mock_inst.close = MagicMock() + + result = runner.invoke(marketplace, ["outdated"]) + assert result.exit_code == 0 + assert "All packages are up to date" in result.output diff --git a/tests/unit/commands/test_marketplace_plugin.py b/tests/unit/commands/test_marketplace_plugin.py new file mode 100644 index 000000000..6c07761ac --- /dev/null +++ b/tests/unit/commands/test_marketplace_plugin.py @@ -0,0 +1,594 @@ +"""Tests for ``apm marketplace package {add,set,remove}`` CLI commands.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.commands.marketplace_plugin import _resolve_ref, _SHA_RE +from apm_cli.core.command_logger import CommandLogger +from apm_cli.marketplace.ref_resolver import RemoteRef + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_yml(tmp_path: Path, content: str | None = None) -> Path: + """Scaffold a valid ``marketplace.yml`` in *tmp_path*.""" + if content is None: + content = textwrap.dedent("""\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: existing-package + source: acme/existing-package + version: ">=1.0.0" + description: An existing package + """) + p = tmp_path / "marketplace.yml" + p.write_text(content, encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runner(): + return CliRunner() + + +# --------------------------------------------------------------------------- +# package add +# --------------------------------------------------------------------------- + + +class TestPackageAdd: + def test_happy_path_no_verify(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "add", + "acme/new-tool", + "--version", + ">=2.0.0", + "--no-verify", + ], + ) + assert result.exit_code == 0, result.output + assert "new-tool" in result.output + + def test_duplicate_name_exits_2(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "add", + "acme/existing-package", + "--version", + ">=1.0.0", + "--no-verify", + ], + ) + assert result.exit_code == 2 + assert "already exists" in result.output + + def test_missing_version_and_ref_no_verify_exits_2( + self, runner, tmp_path, monkeypatch + ): + """With --no-verify and no --ref/--version, auto-resolve fails.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "add", "acme/tool", "--no-verify"], + ) + assert result.exit_code == 2 + assert "Cannot resolve HEAD" in result.output + + def test_version_and_ref_conflict_exits_2( + self, runner, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "add", + "acme/tool", + "--version", + ">=1.0.0", + "--ref", + "abc", + "--no-verify", + ], + ) + assert result.exit_code == 2 + assert "mutually exclusive" in result.output.lower() + + def test_help_renders(self, runner): + result = runner.invoke(marketplace, ["package", "add", "--help"]) + assert result.exit_code == 0 + assert "Add a package" in result.output + + def test_verify_calls_ref_resolver( + self, runner, tmp_path, monkeypatch + ): + """Without --no-verify the command calls list_remote_refs.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + monkeypatch.setattr( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + lambda self, source: [], + ) + result = runner.invoke( + marketplace, + [ + "package", + "add", + "acme/verified-tool", + "--version", + ">=1.0.0", + ], + ) + assert result.exit_code == 0, result.output + assert "verified-tool" in result.output + + +# --------------------------------------------------------------------------- +# package set +# --------------------------------------------------------------------------- + + +class TestPackageSet: + def test_happy_path_update_version( + self, runner, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "set", + "existing-package", + "--version", + ">=2.0.0", + ], + ) + assert result.exit_code == 0, result.output + assert "Updated" in result.output + + def test_package_not_found_exits_2( + self, runner, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "set", + "nonexistent", + "--version", + ">=1.0.0", + ], + ) + assert result.exit_code == 2 + assert "not found" in result.output + + def test_help_renders(self, runner): + result = runner.invoke(marketplace, ["package", "set", "--help"]) + assert result.exit_code == 0 + assert "Update a package" in result.output + + def test_version_and_ref_conflict_exits_2( + self, runner, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "set", + "existing-package", + "--version", + ">=2.0.0", + "--ref", + "abc", + ], + ) + assert result.exit_code == 2 + assert "mutually exclusive" in result.output.lower() + + def test_set_no_fields_errors(self, runner, tmp_path, monkeypatch): + """Calling ``package set`` with no field flags produces an error.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "set", "existing-package"], + ) + assert result.exit_code == 1 + assert "No fields specified" in result.output + + +# --------------------------------------------------------------------------- +# package remove +# --------------------------------------------------------------------------- + + +class TestPackageRemove: + def test_happy_path_with_yes(self, runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "remove", "existing-package", "--yes"], + ) + assert result.exit_code == 0, result.output + assert "Removed" in result.output + + def test_without_yes_non_interactive_cancels( + self, runner, tmp_path, monkeypatch + ): + """Non-interactive mode exits with error asking for --yes.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "remove", "existing-package"], + ) + # Non-interactive guard fires: exit 1 with guidance to use --yes. + assert result.exit_code == 1 + assert "--yes" in result.output + + def test_package_not_found_exits_2( + self, runner, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "remove", "nonexistent", "--yes"], + ) + assert result.exit_code == 2 + assert "not found" in result.output + + def test_help_renders(self, runner): + result = runner.invoke(marketplace, ["package", "remove", "--help"]) + assert result.exit_code == 0 + assert "Remove a package" in result.output + + +# --------------------------------------------------------------------------- +# UX4: --version/--ref mutual exclusivity in package add +# --------------------------------------------------------------------------- + + +class TestPackageAddMutualExclusivity: + """The ``add`` command must reject ``--version`` and ``--ref`` together.""" + + def test_version_and_ref_mutually_exclusive( + self, runner, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", + "add", + "acme/new-tool", + "--version", + "1.0.0", + "--ref", + "main", + "--no-verify", + ], + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output.lower() + + +# --------------------------------------------------------------------------- +# _resolve_ref unit tests +# --------------------------------------------------------------------------- + +_FAKE_SHA = "a" * 40 +_FAKE_SHA_B = "b" * 40 + + +class TestResolveRef: + """Unit tests for the ``_resolve_ref()`` helper.""" + + def _make_logger(self) -> CommandLogger: + return CommandLogger("test", verbose=False) + + def test_version_set_returns_none(self): + """When version is provided, ref resolution is skipped.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref=None, + version=">=1.0.0", no_verify=False, + ) + assert result is None + + def test_no_ref_no_verify_exits(self): + """No --ref, --no-verify → error (cannot resolve without network).""" + with pytest.raises(SystemExit) as exc_info: + _resolve_ref( + self._make_logger(), "acme/tools", ref=None, + version=None, no_verify=True, + ) + assert exc_info.value.code == 2 + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.resolve_ref_sha", + return_value=_FAKE_SHA, + ) + def test_no_ref_resolves_head(self, mock_resolve): + """No --ref, no --version → auto-resolve HEAD.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref=None, + version=None, no_verify=False, + ) + assert result == _FAKE_SHA + mock_resolve.assert_called_once_with("acme/tools", "HEAD") + + def test_explicit_head_no_verify_exits(self): + """--ref HEAD + --no-verify → error.""" + with pytest.raises(SystemExit) as exc_info: + _resolve_ref( + self._make_logger(), "acme/tools", ref="HEAD", + version=None, no_verify=True, + ) + assert exc_info.value.code == 2 + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.resolve_ref_sha", + return_value=_FAKE_SHA, + ) + def test_explicit_head_resolves(self, mock_resolve): + """--ref HEAD → warn + resolve.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref="HEAD", + version=None, no_verify=False, + ) + assert result == _FAKE_SHA + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.resolve_ref_sha", + return_value=_FAKE_SHA, + ) + def test_explicit_head_case_insensitive(self, mock_resolve): + """--ref head (lowercase) → treated as HEAD.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref="head", + version=None, no_verify=False, + ) + assert result == _FAKE_SHA + + def test_sha_returns_as_is(self): + """A 40-char hex SHA is returned unchanged.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref=_FAKE_SHA, + version=None, no_verify=False, + ) + assert result == _FAKE_SHA + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[ + RemoteRef(name="refs/heads/main", sha=_FAKE_SHA_B), + RemoteRef(name="refs/tags/v1.0.0", sha=_FAKE_SHA), + ], + ) + def test_branch_name_resolves_to_sha(self, mock_list): + """--ref main (matching refs/heads/main) → warn + resolve.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref="main", + version=None, no_verify=False, + ) + assert result == _FAKE_SHA_B + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[ + RemoteRef(name="refs/heads/main", sha=_FAKE_SHA), + RemoteRef(name="refs/tags/v1.0.0", sha=_FAKE_SHA_B), + ], + ) + def test_tag_name_returns_as_is(self, mock_list): + """--ref v1.0.0 (not a branch) → returned as-is.""" + result = _resolve_ref( + self._make_logger(), "acme/tools", ref="v1.0.0", + version=None, no_verify=False, + ) + assert result == "v1.0.0" + + +# --------------------------------------------------------------------------- +# Integration: package add with ref auto-resolution +# --------------------------------------------------------------------------- + + +class TestPackageAddRefResolution: + """Integration tests for ref auto-resolution in ``package add``.""" + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.resolve_ref_sha", + return_value=_FAKE_SHA, + ) + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[], + ) + def test_add_no_ref_auto_resolves_head( + self, mock_list, mock_resolve, runner, tmp_path, monkeypatch, + ): + """``package add `` (no --ref, no --version) pins HEAD SHA.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "add", "acme/new-tool"], + ) + assert result.exit_code == 0, result.output + assert "new-tool" in result.output + # Verify the SHA was stored in marketplace.yml. + yml_content = (tmp_path / "marketplace.yml").read_text() + assert _FAKE_SHA in yml_content + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.resolve_ref_sha", + return_value=_FAKE_SHA, + ) + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[], + ) + def test_add_ref_head_warns_and_resolves( + self, mock_list, mock_resolve, runner, tmp_path, monkeypatch, + ): + """``package add --ref HEAD`` warns + stores SHA.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "add", "acme/new-tool", "--ref", "HEAD"], + ) + assert result.exit_code == 0, result.output + assert "mutable ref" in result.output + yml_content = (tmp_path / "marketplace.yml").read_text() + assert _FAKE_SHA in yml_content + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[ + RemoteRef(name="refs/heads/main", sha=_FAKE_SHA), + ], + ) + def test_add_ref_branch_warns_and_resolves( + self, mock_list, runner, tmp_path, monkeypatch, + ): + """``package add --ref main`` warns + stores branch SHA.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "add", "acme/new-tool", "--ref", "main"], + ) + assert result.exit_code == 0, result.output + assert "mutable ref" in result.output + yml_content = (tmp_path / "marketplace.yml").read_text() + assert _FAKE_SHA in yml_content + + def test_add_ref_sha_stores_as_is( + self, runner, tmp_path, monkeypatch, + ): + """``package add --ref `` stores SHA directly.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + [ + "package", "add", "acme/new-tool", + "--ref", _FAKE_SHA, "--no-verify", + ], + ) + assert result.exit_code == 0, result.output + yml_content = (tmp_path / "marketplace.yml").read_text() + assert _FAKE_SHA in yml_content + + +# --------------------------------------------------------------------------- +# Integration: package set with ref auto-resolution +# --------------------------------------------------------------------------- + + +class TestPackageSetRefResolution: + """Integration tests for ref auto-resolution in ``package set``.""" + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.resolve_ref_sha", + return_value=_FAKE_SHA, + ) + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[], + ) + def test_set_ref_head_resolves( + self, mock_list, mock_resolve, runner, tmp_path, monkeypatch, + ): + """``package set --ref HEAD`` resolves to SHA.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "set", "existing-package", "--ref", "HEAD"], + ) + assert result.exit_code == 0, result.output + assert "Updated" in result.output + + @patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + return_value=[ + RemoteRef(name="refs/heads/develop", sha=_FAKE_SHA_B), + ], + ) + def test_set_ref_branch_resolves( + self, mock_list, runner, tmp_path, monkeypatch, + ): + """``package set --ref develop`` resolves branch to SHA.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "set", "existing-package", "--ref", "develop"], + ) + assert result.exit_code == 0, result.output + assert "Updated" in result.output + + def test_set_ref_sha_stores_directly( + self, runner, tmp_path, monkeypatch, + ): + """``package set --ref `` stores SHA without network.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "set", "existing-package", "--ref", _FAKE_SHA], + ) + assert result.exit_code == 0, result.output + + def test_set_ref_nonexistent_package_exits( + self, runner, tmp_path, monkeypatch, + ): + """``package set --ref HEAD`` errors on missing package.""" + monkeypatch.chdir(tmp_path) + _write_yml(tmp_path) + result = runner.invoke( + marketplace, + ["package", "set", "nonexistent", "--ref", "HEAD"], + ) + assert result.exit_code == 2 + assert "not found" in result.output diff --git a/tests/unit/commands/test_marketplace_publish.py b/tests/unit/commands/test_marketplace_publish.py new file mode 100644 index 000000000..44a65ecda --- /dev/null +++ b/tests/unit/commands/test_marketplace_publish.py @@ -0,0 +1,1004 @@ +"""Tests for ``apm marketplace publish`` subcommand.""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.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.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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 marketplace build" 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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.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.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace._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.PrIntegrator") + @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace._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.PrIntegrator") + @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace._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.PrIntegrator") + @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace._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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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.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.PrIntegrator") + @patch("apm_cli.commands.marketplace.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/conftest.py b/tests/unit/marketplace/conftest.py new file mode 100644 index 000000000..f3a7c9084 --- /dev/null +++ b/tests/unit/marketplace/conftest.py @@ -0,0 +1,35 @@ +"""Shared fixtures for ``tests/unit/marketplace/``.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +# Cache the *real* is_enabled so we can delegate non-marketplace flags. +from apm_cli.core.experimental import is_enabled as _real_is_enabled + + +def _marketplace_enabled_is_enabled(name: str) -> bool: + """Stub that forces ``marketplace_authoring`` to True.""" + if name == "marketplace_authoring": + return True + return _real_is_enabled(name) + + +@pytest.fixture(autouse=True) +def _enable_marketplace_flag(): + """Pre-enable the ``marketplace_authoring`` experimental flag. + + Tests in this directory that invoke the ``marketplace`` Click group + need the flag enabled so the group callback does not exit early. + + Patches ``is_enabled`` at the source module so it survives any + config-cache isolation performed by individual tests. + """ + with patch( + "apm_cli.core.experimental.is_enabled", + side_effect=_marketplace_enabled_is_enabled, + ): + yield diff --git a/tests/unit/marketplace/test_builder.py b/tests/unit/marketplace/test_builder.py new file mode 100644 index 000000000..2caa5acc6 --- /dev/null +++ b/tests/unit/marketplace/test_builder.py @@ -0,0 +1,1787 @@ +"""Tests for builder.py -- MarketplaceBuilder, composition, diff, atomic write.""" + +from __future__ import annotations + +import json +import textwrap +from collections import OrderedDict +from pathlib import Path +from typing import Any, Dict, List, Optional +from unittest.mock import patch + +import pytest + +from apm_cli.marketplace.builder import ( + BuildOptions, + BuildReport, + MarketplaceBuilder, + ResolvedPackage, +) +from apm_cli.marketplace.semver import ( + SemVer, + parse_semver, + satisfies_range, +) +from apm_cli.marketplace.errors import ( + BuildError, + HeadNotAllowedError, + NoMatchingVersionError, + RefNotFoundError, +) +from apm_cli.marketplace.ref_resolver import RemoteRef + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SHA_A = "a" * 40 +_SHA_B = "b" * 40 +_SHA_C = "c" * 40 +_SHA_D = "d" * 40 + +_GOLDEN_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "fixtures" + / "marketplace" + / "golden.json" +) + +# Standard marketplace.yml for many tests +_BASIC_YML = """\ +name: acme-tools +description: Curated developer tools by Acme Corp +version: 1.0.0 +owner: + name: Acme Corp + email: tools@acme.example.com + url: https://acme.example.com +metadata: + pluginRoot: plugins + category: developer-tools +packages: + - name: code-reviewer + source: acme/code-reviewer + version: "^2.0.0" + description: Automated code review assistant + tags: [review, quality] + - name: test-generator + source: acme/test-generator + version: "~1.0.0" + subdir: src/plugin + tags: [testing] +""" + + +def _write_yml(tmp_path: Path, content: str) -> Path: + """Write content to marketplace.yml and return the path.""" + p = tmp_path / "marketplace.yml" + p.write_text(textwrap.dedent(content), encoding="utf-8") + return p + + +def _make_refs(*tags: str, branches: Optional[List[str]] = None) -> List[RemoteRef]: + """Build a list of RemoteRef for testing. + + Tags are assigned SHAs starting from 'a' * 40, 'b' * 40, etc. + """ + sha_chars = "abcdef0123456789" + refs: List[RemoteRef] = [] + for i, tag in enumerate(tags): + ch = sha_chars[i % len(sha_chars)] + refs.append(RemoteRef(name=f"refs/tags/{tag}", sha=ch * 40)) + if branches: + for i, branch in enumerate(branches): + ch = sha_chars[(len(tags) + i) % len(sha_chars)] + refs.append(RemoteRef(name=f"refs/heads/{branch}", sha=ch * 40)) + return refs + + +class _MockRefResolver: + """In-process mock for RefResolver -- no subprocess calls.""" + + def __init__(self, refs_by_remote: Optional[Dict[str, List[RemoteRef]]] = None): + self._refs = refs_by_remote or {} + + def list_remote_refs(self, owner_repo: str) -> List[RemoteRef]: + if owner_repo not in self._refs: + from apm_cli.marketplace.errors import GitLsRemoteError + + raise GitLsRemoteError( + package="", + summary=f"Remote '{owner_repo}' not found.", + hint="Check the source.", + ) + return self._refs[owner_repo] + + def close(self) -> None: + pass + + +def _build_with_mock( + tmp_path: Path, + yml_content: str, + refs_by_remote: Dict[str, List[RemoteRef]], + options: Optional[BuildOptions] = None, +) -> BuildReport: + """Build using a mock ref resolver. + + Uses offline=True by default to prevent network calls during tests. + """ + yml_path = _write_yml(tmp_path, yml_content) + opts = options or BuildOptions(offline=True) + builder = MarketplaceBuilder(yml_path, opts) + builder._resolver = _MockRefResolver(refs_by_remote) # type: ignore[assignment] + return builder.build() + + +# --------------------------------------------------------------------------- +# parse_semver +# --------------------------------------------------------------------------- + + +class TestParseSemver: + """Tests for internal semver parser.""" + + def test_basic(self) -> None: + sv = parse_semver("1.2.3") + assert sv is not None + assert (sv.major, sv.minor, sv.patch) == (1, 2, 3) + assert sv.prerelease == "" + assert not sv.is_prerelease + + def test_prerelease(self) -> None: + sv = parse_semver("1.0.0-alpha.1") + assert sv is not None + assert sv.prerelease == "alpha.1" + assert sv.is_prerelease + + def test_build_metadata(self) -> None: + sv = parse_semver("1.0.0+build.42") + assert sv is not None + assert sv.build_meta == "build.42" + assert not sv.is_prerelease + + def test_full(self) -> None: + sv = parse_semver("1.0.0-rc.1+build.5") + assert sv is not None + assert sv.prerelease == "rc.1" + assert sv.build_meta == "build.5" + + def test_invalid(self) -> None: + assert parse_semver("not-a-version") is None + assert parse_semver("1.2") is None + assert parse_semver("") is None + + +class TestSemverComparison: + """Tests for SemVer ordering.""" + + def test_basic_order(self) -> None: + assert parse_semver("1.0.0") < parse_semver("2.0.0") # type: ignore[operator] + assert parse_semver("1.0.0") < parse_semver("1.1.0") # type: ignore[operator] + assert parse_semver("1.0.0") < parse_semver("1.0.1") # type: ignore[operator] + + def test_prerelease_less_than_release(self) -> None: + assert parse_semver("1.0.0-alpha") < parse_semver("1.0.0") # type: ignore[operator] + + def test_prerelease_ordering(self) -> None: + assert parse_semver("1.0.0-alpha") < parse_semver("1.0.0-beta") # type: ignore[operator] + + def test_equality(self) -> None: + assert parse_semver("1.0.0") == parse_semver("1.0.0") + + +# --------------------------------------------------------------------------- +# satisfies_range +# --------------------------------------------------------------------------- + + +class TestSatisfiesRange: + """Tests for semver range matching.""" + + def test_exact(self) -> None: + sv = parse_semver("1.2.3") + assert sv is not None + assert satisfies_range(sv, "1.2.3") + assert not satisfies_range(sv, "1.2.4") + + def test_caret_major(self) -> None: + """^1.2.3 := >=1.2.3, <2.0.0""" + assert satisfies_range(parse_semver("1.2.3"), "^1.2.3") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.9.9"), "^1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("2.0.0"), "^1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.2.2"), "^1.2.3") # type: ignore[arg-type] + + def test_caret_zero_minor(self) -> None: + """^0.2.3 := >=0.2.3, <0.3.0""" + assert satisfies_range(parse_semver("0.2.3"), "^0.2.3") # type: ignore[arg-type] + assert satisfies_range(parse_semver("0.2.9"), "^0.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.3.0"), "^0.2.3") # type: ignore[arg-type] + + def test_caret_zero_zero(self) -> None: + """^0.0.3 := >=0.0.3, <0.0.4""" + assert satisfies_range(parse_semver("0.0.3"), "^0.0.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.0.4"), "^0.0.3") # type: ignore[arg-type] + + def test_tilde(self) -> None: + """~1.2.3 := >=1.2.3, <1.3.0""" + assert satisfies_range(parse_semver("1.2.3"), "~1.2.3") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.2.9"), "~1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.3.0"), "~1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.2.2"), "~1.2.3") # type: ignore[arg-type] + + def test_gte(self) -> None: + assert satisfies_range(parse_semver("2.0.0"), ">=1.0.0") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.0.0"), ">=1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.9.0"), ">=1.0.0") # type: ignore[arg-type] + + def test_gt(self) -> None: + assert satisfies_range(parse_semver("2.0.0"), ">1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.0.0"), ">1.0.0") # type: ignore[arg-type] + + def test_lte(self) -> None: + assert satisfies_range(parse_semver("1.0.0"), "<=1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.0.1"), "<=1.0.0") # type: ignore[arg-type] + + def test_lt(self) -> None: + assert satisfies_range(parse_semver("0.9.0"), "<1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.0.0"), "<1.0.0") # type: ignore[arg-type] + + def test_wildcard_x(self) -> None: + assert satisfies_range(parse_semver("1.2.0"), "1.2.x") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.2.9"), "1.2.x") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.3.0"), "1.2.x") # type: ignore[arg-type] + + def test_wildcard_star(self) -> None: + assert satisfies_range(parse_semver("1.2.0"), "1.2.*") # type: ignore[arg-type] + + def test_combined_range(self) -> None: + """Space-separated constraints are AND-ed.""" + assert satisfies_range(parse_semver("1.5.0"), ">=1.0.0 <2.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("2.0.0"), ">=1.0.0 <2.0.0") # type: ignore[arg-type] + + def test_empty_range(self) -> None: + assert satisfies_range(parse_semver("1.0.0"), "") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Builder -- happy path +# --------------------------------------------------------------------------- + + +class TestBuilderHappyPath: + """Builder integration tests with mock ref resolver.""" + + def test_basic_build(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0", "v2.1.0", "v1.0.0"), + "acme/test-generator": _make_refs("v1.0.0", "v1.0.3", "v1.0.1"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + assert len(report.resolved) == 2 + assert report.resolved[0].name == "code-reviewer" + assert report.resolved[0].ref == "v2.1.0" + assert report.resolved[1].name == "test-generator" + assert report.resolved[1].ref == "v1.0.3" + + def test_output_file_written(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0", "v2.1.0"), + "acme/test-generator": _make_refs("v1.0.0", "v1.0.3"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + assert report.output_path.exists() + data = json.loads(report.output_path.read_text("utf-8")) + assert "plugins" in data + assert len(data["plugins"]) == 2 + + def test_plugin_order_matches_yml(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + assert report.resolved[0].name == "code-reviewer" + assert report.resolved[1].name == "test-generator" + + def test_metadata_passthrough(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert data["metadata"] == {"pluginRoot": "plugins", "category": "developer-tools"} + + def test_metadata_unusual_keys(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + metadata: + pluginRoot: my-plugins + customKey_123: some-value + UPPER_CASE: yes + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert data["metadata"]["customKey_123"] == "some-value" + assert data["metadata"]["UPPER_CASE"] is True + + def test_no_metadata_omitted(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert "metadata" not in data + + def test_description_omitted_when_not_set(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert "description" not in data["plugins"][0] + + +# --------------------------------------------------------------------------- +# APM-only field stripping +# --------------------------------------------------------------------------- + + +class TestFieldStripping: + """Verify APM-only fields are stripped from output.""" + + _APM_ONLY_KEYS = {"version", "ref", "subdir", "tag_pattern", "include_prerelease", "build"} + + def test_no_apm_keys_in_top_level(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert "build" not in data + + def test_no_apm_keys_in_plugins(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + for plugin in data["plugins"]: + for key in self._APM_ONLY_KEYS: + assert key not in plugin, f"APM-only key '{key}' found in plugin" + + def test_source_has_no_apm_keys(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + for plugin in data["plugins"]: + src = plugin["source"] + assert "subdir" not in src + assert "tag_pattern" not in src + assert "include_prerelease" not in src + + +# --------------------------------------------------------------------------- +# Explicit ref pinning +# --------------------------------------------------------------------------- + + +class TestExplicitRef: + """Tests for entries using ``ref:`` instead of ``version:``.""" + + def test_tag_ref(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pinned + source: acme/pinned + ref: v3.0.0 + """ + refs = {"acme/pinned": _make_refs("v3.0.0", "v2.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + assert report.resolved[0].ref == "v3.0.0" + assert report.resolved[0].sha == "a" * 40 + + def test_sha_ref(self, tmp_path: Path) -> None: + sha = "a" * 40 + yml = f"""\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: sha-pinned + source: acme/pinned + ref: "{sha}" + """ + refs = {"acme/pinned": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + assert report.resolved[0].sha == sha + + def test_branch_ref_rejected_without_allow_head(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: branched + source: acme/branched + ref: main + """ + refs = {"acme/branched": _make_refs("v1.0.0", branches=["main"])} + with pytest.raises(HeadNotAllowedError): + _build_with_mock(tmp_path, yml, refs) + + def test_branch_ref_allowed_with_flag(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: branched + source: acme/branched + ref: main + """ + refs = {"acme/branched": _make_refs("v1.0.0", branches=["main"])} + opts = BuildOptions(allow_head=True) + report = _build_with_mock(tmp_path, yml, refs, options=opts) + assert report.resolved[0].ref == "main" + + def test_ref_not_found_raises(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: missing + source: acme/missing + ref: v99.0.0 + """ + refs = {"acme/missing": _make_refs("v1.0.0")} + with pytest.raises(RefNotFoundError): + _build_with_mock(tmp_path, yml, refs) + + +# --------------------------------------------------------------------------- +# Prerelease handling +# --------------------------------------------------------------------------- + + +class TestPrerelease: + """Tests for prerelease inclusion/exclusion.""" + + def test_prerelease_excluded_by_default(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg + source: acme/pkg + version: "^1.0.0" + """ + refs = {"acme/pkg": _make_refs("v1.0.0", "v1.1.0-beta.1", "v1.0.1")} + report = _build_with_mock(tmp_path, yml, refs) + assert report.resolved[0].ref == "v1.0.1" + assert not report.resolved[0].is_prerelease + + def test_prerelease_included_per_entry(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg + source: acme/pkg + version: "^1.0.0" + include_prerelease: true + """ + refs = {"acme/pkg": _make_refs("v1.0.0", "v1.1.0-beta.1", "v1.0.1")} + report = _build_with_mock(tmp_path, yml, refs) + # v1.1.0-beta.1 is highest matching ^1.0.0 + assert report.resolved[0].ref == "v1.1.0-beta.1" + assert report.resolved[0].is_prerelease + + def test_prerelease_included_via_global_option(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg + source: acme/pkg + version: "^1.0.0" + """ + refs = {"acme/pkg": _make_refs("v1.0.0", "v1.1.0-beta.1", "v1.0.1")} + opts = BuildOptions(include_prerelease=True) + report = _build_with_mock(tmp_path, yml, refs, options=opts) + assert report.resolved[0].ref == "v1.1.0-beta.1" + + +# --------------------------------------------------------------------------- +# Tag pattern override +# --------------------------------------------------------------------------- + + +class TestTagPatternOverride: + """Tests for tag pattern precedence.""" + + def test_entry_pattern_wins(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + build: + tagPattern: "v{version}" + packages: + - name: pkg + source: acme/pkg + version: "^1.0.0" + tag_pattern: "release-{version}" + """ + refs = {"acme/pkg": _make_refs("v1.0.0", "release-1.0.0", "release-1.1.0")} + report = _build_with_mock(tmp_path, yml, refs) + assert report.resolved[0].ref == "release-1.1.0" + + def test_build_pattern_fallback(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + build: + tagPattern: "release-{version}" + packages: + - name: pkg + source: acme/pkg + version: "^1.0.0" + """ + refs = {"acme/pkg": _make_refs("v1.0.0", "release-1.0.0", "release-1.1.0")} + report = _build_with_mock(tmp_path, yml, refs) + assert report.resolved[0].ref == "release-1.1.0" + + +# --------------------------------------------------------------------------- +# No match error +# --------------------------------------------------------------------------- + + +class TestNoMatch: + """Tests for version range producing no candidates.""" + + def test_no_matching_version(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg + source: acme/pkg + version: "^5.0.0" + """ + refs = {"acme/pkg": _make_refs("v1.0.0", "v2.0.0")} + with pytest.raises(NoMatchingVersionError, match="5.0.0"): + _build_with_mock(tmp_path, yml, refs) + + +# --------------------------------------------------------------------------- +# continue_on_error +# --------------------------------------------------------------------------- + + +class TestContinueOnError: + """Tests for --continue-on-error behaviour.""" + + def test_errors_collected(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: good + source: acme/good + version: "^1.0.0" + - name: bad + source: acme/bad + version: "^99.0.0" + """ + refs = { + "acme/good": _make_refs("v1.0.0"), + "acme/bad": _make_refs("v1.0.0"), + } + opts = BuildOptions(continue_on_error=True) + report = _build_with_mock(tmp_path, yml, refs, options=opts) + assert len(report.resolved) == 1 + assert len(report.errors) == 1 + assert report.errors[0][0] == "bad" + + +# --------------------------------------------------------------------------- +# Diff classification +# --------------------------------------------------------------------------- + + +class TestDiffClassification: + """Tests for the diff logic (added, updated, unchanged, removed).""" + + def test_first_build_all_added(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + assert report.added_count == 1 + assert report.unchanged_count == 0 + assert report.updated_count == 0 + assert report.removed_count == 0 + + def test_unchanged_on_rebuild(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + # First build + _build_with_mock(tmp_path, yml, refs) + # Second build -- same refs + report = _build_with_mock(tmp_path, yml, refs) + assert report.unchanged_count == 1 + assert report.added_count == 0 + + def test_updated_on_sha_change(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs_v1 = {"acme/pkg1": _make_refs("v1.0.0")} + _build_with_mock(tmp_path, yml, refs_v1) + # Now add v1.1.0 (different SHA) + refs_v2 = {"acme/pkg1": _make_refs("v1.0.0", "v1.1.0")} + report = _build_with_mock(tmp_path, yml, refs_v2) + assert report.updated_count == 1 + + def test_removed_on_package_drop(self, tmp_path: Path) -> None: + yml_with = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + - name: pkg2 + source: acme/pkg2 + version: "^1.0.0" + """ + yml_without = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = { + "acme/pkg1": _make_refs("v1.0.0"), + "acme/pkg2": _make_refs("v1.0.0"), + } + _build_with_mock(tmp_path, yml_with, refs) + report = _build_with_mock(tmp_path, yml_without, refs) + assert report.removed_count == 1 + assert report.unchanged_count == 1 + + +# --------------------------------------------------------------------------- +# Dry run +# --------------------------------------------------------------------------- + + +class TestDryRun: + """Tests for dry-run mode.""" + + def test_dry_run_does_not_write(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + opts = BuildOptions(dry_run=True) + report = _build_with_mock(tmp_path, yml, refs, options=opts) + assert report.dry_run is True + assert not report.output_path.exists() + + def test_dry_run_still_produces_report(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + opts = BuildOptions(dry_run=True) + report = _build_with_mock(tmp_path, yml, refs, options=opts) + assert len(report.resolved) == 1 + + +# --------------------------------------------------------------------------- +# Atomic write +# --------------------------------------------------------------------------- + + +class TestAtomicWrite: + """Tests for atomic file writing.""" + + def test_atomic_write_creates_file(self, tmp_path: Path) -> None: + path = tmp_path / "test.json" + MarketplaceBuilder._atomic_write(path, '{"hello": "world"}\n') + assert path.exists() + assert json.loads(path.read_text("utf-8")) == {"hello": "world"} + + def test_atomic_write_replaces_existing(self, tmp_path: Path) -> None: + path = tmp_path / "test.json" + path.write_text('{"old": true}\n', encoding="utf-8") + MarketplaceBuilder._atomic_write(path, '{"new": true}\n') + assert json.loads(path.read_text("utf-8")) == {"new": True} + + def test_no_tmp_file_left(self, tmp_path: Path) -> None: + path = tmp_path / "test.json" + MarketplaceBuilder._atomic_write(path, '{"ok": true}\n') + tmp_file = path.with_suffix(path.suffix + ".tmp") + assert not tmp_file.exists() + + +# --------------------------------------------------------------------------- +# Owner optional fields +# --------------------------------------------------------------------------- + + +class TestOwnerFields: + """Tests for owner field omission.""" + + def test_owner_email_omitted_when_empty(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert "email" not in data["owner"] + assert "url" not in data["owner"] + + def test_owner_full(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + assert data["owner"]["email"] == "tools@acme.example.com" + assert data["owner"]["url"] == "https://acme.example.com" + + +# --------------------------------------------------------------------------- +# Source composition (subdir -> path) +# --------------------------------------------------------------------------- + + +class TestSourceComposition: + """Tests for the source object in plugins.""" + + def test_subdir_becomes_path(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + tg = data["plugins"][1] + assert tg["source"]["path"] == "src/plugin" + + def test_no_subdir_no_path(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads(report.output_path.read_text("utf-8")) + cr = data["plugins"][0] + assert "path" not in cr["source"] + + +# --------------------------------------------------------------------------- +# Deterministic output (round-trip) +# --------------------------------------------------------------------------- + + +class TestDeterministicOutput: + """Verify that same inputs produce byte-identical output.""" + + def test_round_trip(self, tmp_path: Path) -> None: + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + # First build + _build_with_mock(tmp_path, _BASIC_YML, refs) + content1 = (tmp_path / "marketplace.json").read_bytes() + + # Second build (overwrite) + _build_with_mock(tmp_path, _BASIC_YML, refs) + content2 = (tmp_path / "marketplace.json").read_bytes() + + assert content1 == content2 + + def test_json_key_order(self, tmp_path: Path) -> None: + """Top-level keys appear in the documented order.""" + refs = { + "acme/code-reviewer": _make_refs("v2.0.0"), + "acme/test-generator": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, _BASIC_YML, refs) + data = json.loads( + report.output_path.read_text("utf-8"), + object_pairs_hook=OrderedDict, + ) + keys = list(data.keys()) + assert keys == ["name", "description", "version", "owner", "metadata", "plugins"] + + +# --------------------------------------------------------------------------- +# Golden file +# --------------------------------------------------------------------------- + + +class TestGoldenFile: + """Tests using the golden fixture file.""" + + def test_golden_file_exists_and_parses(self) -> None: + assert _GOLDEN_PATH.exists(), f"Golden file not found: {_GOLDEN_PATH}" + data = json.loads(_GOLDEN_PATH.read_text("utf-8")) + assert "name" in data + assert "plugins" in data + assert isinstance(data["plugins"], list) + + def test_golden_file_top_level_shape(self) -> None: + data = json.loads(_GOLDEN_PATH.read_text("utf-8")) + assert isinstance(data["name"], str) + assert isinstance(data["description"], str) + assert isinstance(data["version"], str) + assert isinstance(data["owner"], dict) + assert "name" in data["owner"] + + def test_golden_file_plugin_shape(self) -> None: + data = json.loads(_GOLDEN_PATH.read_text("utf-8")) + for plugin in data["plugins"]: + assert "name" in plugin + assert "tags" in plugin + assert "source" in plugin + src = plugin["source"] + assert src["type"] == "github" + assert "repository" in src + assert "ref" in src + assert "commit" in src + + def test_golden_file_no_apm_keys(self) -> None: + data = json.loads(_GOLDEN_PATH.read_text("utf-8")) + assert "build" not in data + for plugin in data["plugins"]: + assert "subdir" not in plugin + assert "tag_pattern" not in plugin + assert "include_prerelease" not in plugin + + def test_golden_file_trailing_newline(self) -> None: + text = _GOLDEN_PATH.read_text("utf-8") + assert text.endswith("\n") + assert not text.endswith("\n\n") + + +# --------------------------------------------------------------------------- +# compose_marketplace_json direct tests +# --------------------------------------------------------------------------- + + +class TestComposeMarketplaceJson: + """Direct tests for the composition method.""" + + def test_compose_returns_ordered_dict(self, tmp_path: Path) -> None: + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, BuildOptions(offline=True)) + resolved = [ + ResolvedPackage( + name="test-pkg", + source_repo="acme/test-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=("testing",), + is_prerelease=False, + ), + ] + result = builder.compose_marketplace_json(resolved) + assert isinstance(result, OrderedDict) + assert result["name"] == "acme-tools" + assert result["plugins"][0]["source"]["type"] == "github" + + def test_empty_packages(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + """ + yml_path = _write_yml(tmp_path, yml) + builder = MarketplaceBuilder(yml_path, BuildOptions(offline=True)) + result = builder.compose_marketplace_json([]) + assert result["plugins"] == [] + + +# --------------------------------------------------------------------------- +# Output override +# --------------------------------------------------------------------------- + + +class TestOutputOverride: + """Tests for --output flag.""" + + def test_custom_output_path(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + custom_out = tmp_path / "custom" / "output.json" + opts = BuildOptions(output_override=custom_out) + report = _build_with_mock(tmp_path, yml, refs, options=opts) + assert report.output_path == custom_out + assert custom_out.exists() + + +# --------------------------------------------------------------------------- +# JSON formatting +# --------------------------------------------------------------------------- + + +class TestJsonFormatting: + """Tests for JSON serialization rules.""" + + def test_two_space_indent(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + text = report.output_path.read_text("utf-8") + # Check indentation: second line should start with 2 spaces + lines = text.split("\n") + assert lines[1].startswith(" ") + + def test_trailing_newline(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg1 + source: acme/pkg1 + version: "^1.0.0" + """ + refs = {"acme/pkg1": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + text = report.output_path.read_text("utf-8") + assert text.endswith("\n") + + +# --------------------------------------------------------------------------- +# Empty packages list +# --------------------------------------------------------------------------- + + +class TestEmptyPackages: + """Tests for marketplace with no packages.""" + + def test_empty_packages_produces_empty_plugins(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: [] + """ + report = _build_with_mock(tmp_path, yml, {}) + assert len(report.resolved) == 0 + data = json.loads(report.output_path.read_text("utf-8")) + assert data["plugins"] == [] + + +# --------------------------------------------------------------------------- +# Duplicate package name warnings +# --------------------------------------------------------------------------- + + +class TestDuplicateNameWarnings: + """Tests for defence-in-depth duplicate name detection in the builder.""" + + def test_no_warnings_when_names_unique(self, tmp_path: Path) -> None: + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: pkg-alpha + source: acme/pkg-alpha + version: "^1.0.0" + - name: pkg-beta + source: acme/pkg-beta + version: "^1.0.0" + """ + refs = { + "acme/pkg-alpha": _make_refs("v1.0.0"), + "acme/pkg-beta": _make_refs("v1.0.0"), + } + report = _build_with_mock(tmp_path, yml, refs) + assert report.warnings == () + + def test_duplicate_names_produce_warning(self, tmp_path: Path) -> None: + """Bypass yml_schema by feeding resolved packages directly.""" + yml_path = _write_yml(tmp_path, """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: alpha + source: acme/alpha + version: "^1.0.0" + """) + refs = {"acme/alpha": _make_refs("v1.0.0")} + builder = MarketplaceBuilder(yml_path, BuildOptions(offline=True)) + builder._resolver = _MockRefResolver(refs) # type: ignore[assignment] + + # Craft two resolved packages with the same name but different paths. + dupes = [ + ResolvedPackage( + name="learning", + source_repo="acme/repo", + subdir="general/learning", + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ResolvedPackage( + name="learning", + source_repo="acme/repo", + subdir="special/learning", + ref="v1.0.0", + sha=_SHA_B, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ] + builder.compose_marketplace_json(dupes) + warnings = getattr(builder, "_compose_warnings", ()) + assert len(warnings) == 1 + assert "Duplicate package name 'learning'" in warnings[0] + assert "general/learning" in warnings[0] + assert "special/learning" in warnings[0] + + def test_duplicate_names_without_subdir_uses_repository( + self, tmp_path: Path, + ) -> None: + """When subdir is absent, the warning should reference the repository.""" + yml_path = _write_yml(tmp_path, """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: alpha + source: acme/alpha + version: "^1.0.0" + """) + builder = MarketplaceBuilder(yml_path, BuildOptions(offline=True)) + builder._resolver = _MockRefResolver({ # type: ignore[assignment] + "acme/alpha": _make_refs("v1.0.0"), + }) + + dupes = [ + ResolvedPackage( + name="tool", + source_repo="acme/tool-a", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ResolvedPackage( + name="tool", + source_repo="acme/tool-b", + subdir=None, + ref="v1.0.0", + sha=_SHA_B, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ] + builder.compose_marketplace_json(dupes) + warnings = getattr(builder, "_compose_warnings", ()) + assert len(warnings) == 1 + assert "acme/tool-a" in warnings[0] + assert "acme/tool-b" in warnings[0] + + def test_build_report_carries_warnings(self, tmp_path: Path) -> None: + """BuildReport.warnings is empty for a clean build.""" + yml = """\ + name: test-mkt + description: Test + version: 1.0.0 + owner: + name: Test + packages: + - name: solo + source: acme/solo + version: "^1.0.0" + """ + refs = {"acme/solo": _make_refs("v1.0.0")} + report = _build_with_mock(tmp_path, yml, refs) + assert isinstance(report.warnings, tuple) + assert len(report.warnings) == 0 + + +# --------------------------------------------------------------------------- +# _fetch_remote_metadata tests +# --------------------------------------------------------------------------- + + +class TestFetchRemoteMetadata: + """Tests for best-effort remote apm.yml metadata fetching.""" + + def _make_pkg( + self, + *, + name: str = "my-tool", + source_repo: str = "acme/my-tool", + subdir: Optional[str] = None, + sha: str = _SHA_A, + ) -> ResolvedPackage: + return ResolvedPackage( + name=name, + source_repo=source_repo, + subdir=subdir, + ref="v1.0.0", + sha=sha, + requested_version="^1.0.0", + tags=("testing",), + is_prerelease=False, + ) + + def _make_builder(self, tmp_path: Path) -> MarketplaceBuilder: + yml_path = _write_yml(tmp_path, _BASIC_YML) + return MarketplaceBuilder(yml_path) + + def test_happy_path_returns_description_and_version(self, tmp_path: Path) -> None: + """urlopen returns valid YAML with description and version.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"name: my-tool\ndescription: Remote tool description\nversion: 2.3.1\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["description"] == "Remote tool description" + assert result["version"] == "2.3.1" + + def test_happy_path_with_subdir(self, tmp_path: Path) -> None: + """URL includes subdir when present on the package.""" + pkg = self._make_pkg(subdir="src/plugin") + builder = self._make_builder(tmp_path) + yaml_body = b"description: Nested plugin desc\nversion: 1.0.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + return_value=mock_resp, + ) as mock_open: + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["description"] == "Nested plugin desc" + # Verify the URL contains the subdir + call_args = mock_open.call_args + req = call_args[0][0] + assert "src/plugin/apm.yml" in req.full_url + + def test_description_only(self, tmp_path: Path) -> None: + """YAML with description but no version.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"name: my-tool\ndescription: Only desc\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["description"] == "Only desc" + assert "version" not in result + + def test_version_only(self, tmp_path: Path) -> None: + """YAML with version but no description.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"name: my-tool\nversion: 3.0.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["version"] == "3.0.0" + assert "description" not in result + + def test_network_failure_returns_none(self, tmp_path: Path) -> None: + """URLError from urlopen -> returns None, no crash.""" + import urllib.error + + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + side_effect=urllib.error.URLError("connection refused"), + ): + result = builder._fetch_remote_metadata(pkg) + assert result is None + + def test_http_404_returns_none(self, tmp_path: Path) -> None: + """HTTP 404 -> returns None.""" + import urllib.error + + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + side_effect=urllib.error.HTTPError( + url="", code=404, msg="Not Found", hdrs=None, fp=None # type: ignore[arg-type] + ), + ): + result = builder._fetch_remote_metadata(pkg) + assert result is None + + def test_no_description_no_version_returns_none(self, tmp_path: Path) -> None: + """YAML without description or version -> returns None.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"name: my-tool\ntags:\n - util\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is None + + def test_empty_description_excluded(self, tmp_path: Path) -> None: + """YAML with empty description string -> excluded from result.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b'name: my-tool\ndescription: ""\nversion: 1.0.0\n' + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert "description" not in result + assert result["version"] == "1.0.0" + + def test_non_dict_yaml_returns_none(self, tmp_path: Path) -> None: + """YAML that parses to a non-dict (e.g. a list) -> returns None.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"- item1\n- item2\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is None + + def test_invalid_yaml_returns_none(self, tmp_path: Path) -> None: + """Unparseable YAML -> returns None.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"{{{{not: yaml at all" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is None + + def test_numeric_version_coerced_to_string(self, tmp_path: Path) -> None: + """YAML version as float (e.g. 1.0) is coerced to string.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + yaml_body = b"name: my-tool\nversion: 1.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch("apm_cli.marketplace.builder.urllib.request.urlopen", return_value=mock_resp): + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["version"] == "1.0" + + def test_auth_header_added_when_token_present(self, tmp_path: Path) -> None: + """When _github_token is set, Authorization header is included.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + builder._github_token = "ghp_faketoken123" + yaml_body = b"description: Private plugin\nversion: 1.0.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + return_value=mock_resp, + ) as mock_open: + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["description"] == "Private plugin" + # Verify Authorization header was set on the Request + call_args = mock_open.call_args + req = call_args[0][0] + assert req.get_header("Authorization") == "token ghp_faketoken123" + + def test_no_auth_header_when_no_token(self, tmp_path: Path) -> None: + """When _github_token is None, no Authorization header is set.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + builder._github_token = None + yaml_body = b"description: Public plugin\nversion: 2.0.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + return_value=mock_resp, + ) as mock_open: + result = builder._fetch_remote_metadata(pkg) + assert result is not None + call_args = mock_open.call_args + req = call_args[0][0] + assert req.get_header("Authorization") is None + + +class _FakeHTTPResponse: + """Minimal file-like mock for urllib.request.urlopen return value.""" + + def __init__(self, data: bytes) -> None: + self._data = data + + def read(self) -> bytes: + return self._data + + def __enter__(self): # type: ignore[no-untyped-def] + return self + + def __exit__(self, *args: object) -> None: + pass + + +# --------------------------------------------------------------------------- +# compose_marketplace_json metadata enrichment tests +# --------------------------------------------------------------------------- + + +class TestMetadataEnrichment: + """Tests for compose_marketplace_json remote metadata enrichment.""" + + def test_enrichment_populates_description_and_version(self, tmp_path: Path) -> None: + """Package gets description and version from remote fetch.""" + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path) + resolved = [ + ResolvedPackage( + name="enriched-pkg", + source_repo="acme/enriched-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=("test",), + is_prerelease=False, + ), + ] + with patch.object( + MarketplaceBuilder, + "_fetch_remote_metadata", + return_value={"description": "Fetched desc", "version": "1.2.3"}, + ): + result = builder.compose_marketplace_json(resolved) + assert result["plugins"][0]["description"] == "Fetched desc" + assert result["plugins"][0]["version"] == "1.2.3" + + def test_remote_fetch_failure_leaves_no_description_or_version( + self, tmp_path: Path, + ) -> None: + """When remote fetch returns None, plugin has no description or version.""" + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path) + resolved = [ + ResolvedPackage( + name="fail-pkg", + source_repo="acme/fail-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=("test",), + is_prerelease=False, + ), + ] + with patch.object( + MarketplaceBuilder, + "_fetch_remote_metadata", + return_value=None, + ): + result = builder.compose_marketplace_json(resolved) + assert "description" not in result["plugins"][0] + assert "version" not in result["plugins"][0] + + def test_offline_mode_skips_fetch(self, tmp_path: Path) -> None: + """When offline=True, no remote fetch is attempted.""" + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, BuildOptions(offline=True)) + resolved = [ + ResolvedPackage( + name="offline-pkg", + source_repo="acme/offline-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=("test",), + is_prerelease=False, + ), + ] + with patch.object( + MarketplaceBuilder, + "_fetch_remote_metadata", + ) as mock_fetch: + result = builder.compose_marketplace_json(resolved) + mock_fetch.assert_not_called() + assert "description" not in result["plugins"][0] + assert "version" not in result["plugins"][0] + + def test_partial_metadata_only_description(self, tmp_path: Path) -> None: + """Remote returns only description, no version.""" + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path) + resolved = [ + ResolvedPackage( + name="desc-only-pkg", + source_repo="acme/desc-only-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ] + with patch.object( + MarketplaceBuilder, + "_fetch_remote_metadata", + return_value={"description": "Only desc"}, + ): + result = builder.compose_marketplace_json(resolved) + assert result["plugins"][0]["description"] == "Only desc" + assert "version" not in result["plugins"][0] + + def test_partial_metadata_only_version(self, tmp_path: Path) -> None: + """Remote returns only version, no description.""" + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path) + resolved = [ + ResolvedPackage( + name="ver-only-pkg", + source_repo="acme/ver-only-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ] + with patch.object( + MarketplaceBuilder, + "_fetch_remote_metadata", + return_value={"version": "4.5.6"}, + ): + result = builder.compose_marketplace_json(resolved) + assert "description" not in result["plugins"][0] + assert result["plugins"][0]["version"] == "4.5.6" + + +# --------------------------------------------------------------------------- +# Auth token resolution tests +# --------------------------------------------------------------------------- + + +class TestResolveGitHubToken: + """Tests for _resolve_github_token and auth integration in _prefetch_metadata.""" + + def test_resolve_token_returns_token_from_auth_resolver(self, tmp_path: Path) -> None: + """When AuthResolver returns a token, _resolve_github_token returns it.""" + from unittest.mock import MagicMock + + mock_resolver = MagicMock() + mock_ctx = MagicMock() + mock_ctx.token = "ghp_resolved_token" + mock_ctx.source = "GITHUB_TOKEN" + mock_resolver.resolve.return_value = mock_ctx + + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, auth_resolver=mock_resolver) + token = builder._resolve_github_token() + assert token == "ghp_resolved_token" + mock_resolver.resolve.assert_called_once_with("github.com") + + def test_resolve_token_returns_none_when_no_token(self, tmp_path: Path) -> None: + """When AuthResolver returns no token, _resolve_github_token returns None.""" + from unittest.mock import MagicMock + + mock_resolver = MagicMock() + mock_ctx = MagicMock() + mock_ctx.token = None + mock_resolver.resolve.return_value = mock_ctx + + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, auth_resolver=mock_resolver) + token = builder._resolve_github_token() + assert token is None + + def test_resolve_token_returns_none_on_exception(self, tmp_path: Path) -> None: + """When AuthResolver raises, _resolve_github_token returns None (best-effort).""" + from unittest.mock import MagicMock + + mock_resolver = MagicMock() + mock_resolver.resolve.side_effect = RuntimeError("auth explosion") + + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, auth_resolver=mock_resolver) + token = builder._resolve_github_token() + assert token is None + + def test_resolve_token_lazy_creates_resolver(self, tmp_path: Path) -> None: + """When no auth_resolver is provided, one is created lazily.""" + from unittest.mock import MagicMock + + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path) # no auth_resolver + assert builder._auth_resolver is None + + mock_ctx = MagicMock() + mock_ctx.token = "ghp_lazy_token" + mock_ctx.source = "GH_TOKEN" + with patch("apm_cli.core.auth.AuthResolver") as MockAuthCls: + MockAuthCls.return_value.resolve.return_value = mock_ctx + token = builder._resolve_github_token() + assert token == "ghp_lazy_token" + assert builder._auth_resolver is not None + + def test_prefetch_metadata_resolves_token_before_fetching( + self, tmp_path: Path, + ) -> None: + """_prefetch_metadata resolves the token once, then workers use it.""" + from unittest.mock import MagicMock + + mock_resolver = MagicMock() + mock_ctx = MagicMock() + mock_ctx.token = "ghp_prefetch_token" + mock_ctx.source = "GITHUB_APM_PAT" + mock_resolver.resolve.return_value = mock_ctx + + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, auth_resolver=mock_resolver) + resolved = [ + ResolvedPackage( + name="auth-pkg", + source_repo="acme/auth-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ] + yaml_body = b"description: Auth test\nversion: 1.0.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + return_value=mock_resp, + ) as mock_open: + results = builder._prefetch_metadata(resolved) + # Token was resolved + assert builder._github_token == "ghp_prefetch_token" + # Request included auth header + call_args = mock_open.call_args + req = call_args[0][0] + assert req.get_header("Authorization") == "token ghp_prefetch_token" + # Result was populated + assert "auth-pkg" in results + assert results["auth-pkg"]["description"] == "Auth test" + + def test_prefetch_metadata_works_without_token(self, tmp_path: Path) -> None: + """_prefetch_metadata works even when no token is available.""" + from unittest.mock import MagicMock + + mock_resolver = MagicMock() + mock_ctx = MagicMock() + mock_ctx.token = None + mock_resolver.resolve.return_value = mock_ctx + + yml_path = _write_yml(tmp_path, _BASIC_YML) + builder = MarketplaceBuilder(yml_path, auth_resolver=mock_resolver) + resolved = [ + ResolvedPackage( + name="public-pkg", + source_repo="acme/public-pkg", + subdir=None, + ref="v1.0.0", + sha=_SHA_A, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ), + ] + yaml_body = b"description: Public test\nversion: 2.0.0\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + return_value=mock_resp, + ) as mock_open: + results = builder._prefetch_metadata(resolved) + # No token was set + assert builder._github_token is None + # Request had no auth header + call_args = mock_open.call_args + req = call_args[0][0] + assert req.get_header("Authorization") is None + # Result was still populated (public repo) + assert "public-pkg" in results diff --git a/tests/unit/marketplace/test_git_stderr.py b/tests/unit/marketplace/test_git_stderr.py new file mode 100644 index 000000000..d6f823c28 --- /dev/null +++ b/tests/unit/marketplace/test_git_stderr.py @@ -0,0 +1,459 @@ +"""Tests for git stderr translator. + +Covers every ``GitErrorKind`` classification branch, hint substitution, +raw-stderr truncation, summary length cap, and the UNKNOWN fallback. +""" + +from __future__ import annotations + +import pytest + +from apm_cli.marketplace.git_stderr import ( + GitErrorKind, + TranslatedGitError, + translate_git_stderr, +) + + +# --------------------------------------------------------------------------- +# AUTH classification +# --------------------------------------------------------------------------- + +_AUTH_STDERR_SAMPLES = [ + pytest.param( + "fatal: Authentication failed for 'https://github.com/acme/tools.git'", + id="authentication-failed", + ), + pytest.param( + "remote: Invalid credentials", + id="invalid-credentials", + ), + pytest.param( + "fatal: could not read Password for 'https://github.com': terminal prompts disabled", + id="could-not-read-password", + ), + pytest.param( + "Permission denied (publickey).\r\nfatal: Could not read from remote repository.", + id="permission-denied-publickey", + ), + pytest.param( + "The requested URL returned error: 403 Forbidden", + id="403-forbidden-url", + ), + pytest.param( + "The requested URL returned error: 401 Unauthorized", + id="401-unauthorized-url", + ), + pytest.param( + "fatal: Authentication required\nremote: Repository not found.", + id="fatal-authentication-takes-priority", + ), + pytest.param( + "remote: write access to repository not allowed", + id="remote-write-access", + ), + pytest.param( + "Please make sure you have the correct access rights\n" + "and the repository exists.", + id="correct-access-rights", + ), + pytest.param( + "The requested URL returned error: 401", + id="url-error-401", + ), + pytest.param( + "The requested URL returned error: 403", + id="url-error-403", + ), +] + + +class TestAuthClassification: + """AUTH patterns are recognized regardless of casing.""" + + @pytest.mark.parametrize("stderr", _AUTH_STDERR_SAMPLES) + def test_auth_detected(self, stderr: str) -> None: + result = translate_git_stderr(stderr, operation="push", remote="acme/tools") + assert result.kind == GitErrorKind.AUTH + + def test_auth_summary(self) -> None: + result = translate_git_stderr( + "fatal: Authentication failed", operation="ls-remote" + ) + assert result.summary == "Git authentication failed during ls-remote." + + 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 + + def test_auth_case_insensitive(self) -> None: + result = translate_git_stderr("FATAL: AUTHENTICATION FAILED") + assert result.kind == GitErrorKind.AUTH + + +# --------------------------------------------------------------------------- +# NOT_FOUND classification +# --------------------------------------------------------------------------- + +_NOT_FOUND_STDERR_SAMPLES = [ + pytest.param( + "ERROR: Repository not found.\nfatal: Could not read from remote repository.", + id="repository-not-found", + ), + pytest.param( + "fatal: 'https://github.com/acme/nope' does not appear to be a git repository", + id="not-a-git-repo", + ), + pytest.param( + "error: pathspec 'v99.0.0' did not match any file(s) known to git -- not a valid ref", + id="not-a-valid-ref", + ), + pytest.param( + "fatal: couldn't find remote ref refs/heads/nonexistent", + id="couldnt-find-remote-ref", + ), + pytest.param( + "fatal: could not resolve HEAD to a revision", + id="could-not-resolve", + ), + pytest.param( + "The requested URL returned error: 404", + id="url-error-404", + ), + pytest.param( + "fatal: no such ref: refs/tags/v0.0.0", + id="no-such-ref", + ), + pytest.param( + "fatal: unknown ref: refs/heads/oops", + id="unknown-ref", + ), +] + + +class TestNotFoundClassification: + """NOT_FOUND patterns are recognized.""" + + @pytest.mark.parametrize("stderr", _NOT_FOUND_STDERR_SAMPLES) + def test_not_found_detected(self, stderr: str) -> None: + result = translate_git_stderr(stderr, operation="fetch") + assert result.kind == GitErrorKind.NOT_FOUND + + def test_not_found_summary(self) -> None: + result = translate_git_stderr( + "Repository not found", operation="clone" + ) + assert result.summary == "Git ref or repository not found during clone." + + def test_hint_with_remote(self) -> None: + result = translate_git_stderr( + "Repository not found", remote="acme/code-reviewer" + ) + assert "'acme/code-reviewer'" in result.hint + assert "ref is spelled correctly" in result.hint + + def test_hint_without_remote(self) -> None: + result = translate_git_stderr("Repository not found") + assert "the remote" in result.hint + assert "ref is spelled correctly" in result.hint + + +# --------------------------------------------------------------------------- +# TIMEOUT classification +# --------------------------------------------------------------------------- + +_TIMEOUT_STDERR_SAMPLES = [ + pytest.param( + "fatal: unable to access 'https://github.com/acme/repo.git/': Operation timed out", + id="operation-timed-out", + ), + pytest.param( + "fatal: unable to access: Connection timed out after 30001 milliseconds", + id="connection-timed-out", + ), + pytest.param( + "fatal: unable to access: Could not resolve host: github.com", + id="could-not-resolve-host", + ), + pytest.param( + "fatal: unable to connect: Connection refused", + id="connection-refused", + ), + pytest.param( + "fatal: unable to access: Network is unreachable", + id="network-unreachable", + ), + pytest.param( + "fatal: unable to look up github.com (Temporary failure in name resolution)", + id="temporary-failure-dns", + ), + pytest.param( + "LibreSSL SSL_read: Connection reset by peer, errno 54", + id="ssl-read-connection-reset", + ), + pytest.param( + "error: RPC failed; curl 56 GnuTLS recv error (-110): early EOF", + id="early-eof", + ), + pytest.param( + "error: RPC failed; curl 18 transfer closed", + id="rpc-failed", + ), +] + + +class TestTimeoutClassification: + """TIMEOUT patterns are recognized.""" + + @pytest.mark.parametrize("stderr", _TIMEOUT_STDERR_SAMPLES) + def test_timeout_detected(self, stderr: str) -> None: + result = translate_git_stderr(stderr, operation="fetch") + assert result.kind == GitErrorKind.TIMEOUT + + def test_timeout_summary(self) -> None: + result = translate_git_stderr( + "Connection timed out", operation="push" + ) + assert result.summary == "Git network timeout during push." + + def test_timeout_hint(self) -> None: + result = translate_git_stderr("Connection timed out") + assert "Retry or check your connection" in result.hint + + +# --------------------------------------------------------------------------- +# UNKNOWN fallback +# --------------------------------------------------------------------------- + + +class TestUnknownFallback: + """Unrecognized stderr maps to UNKNOWN.""" + + def test_unknown_kind(self) -> None: + result = translate_git_stderr( + "error: something completely unexpected happened" + ) + assert result.kind == GitErrorKind.UNKNOWN + + def test_unknown_summary_with_exit_code(self) -> None: + result = translate_git_stderr( + "kaboom", exit_code=1, operation="rebase" + ) + assert result.summary == "Git failed during rebase (exit 1)." + + def test_unknown_summary_without_exit_code(self) -> None: + result = translate_git_stderr("kaboom", operation="rebase") + assert result.summary == "Git failed during rebase." + + def test_unknown_hint(self) -> None: + result = translate_git_stderr("kaboom", operation="merge") + assert result.hint == "Git failed during merge. See raw stderr above." + + def test_empty_stderr(self) -> None: + result = translate_git_stderr("") + assert result.kind == GitErrorKind.UNKNOWN + assert result.raw == "" + + +# --------------------------------------------------------------------------- +# Priority ordering (AUTH > NOT_FOUND > TIMEOUT) +# --------------------------------------------------------------------------- + + +class TestPriorityOrder: + """When stderr matches multiple categories, priority order wins.""" + + def test_auth_beats_not_found(self) -> None: + stderr = ( + "fatal: Authentication failed\n" + "ERROR: Repository not found." + ) + result = translate_git_stderr(stderr) + assert result.kind == GitErrorKind.AUTH + + def test_auth_beats_timeout(self) -> None: + stderr = ( + "fatal: Authentication failed\n" + "error: RPC failed; curl 56" + ) + result = translate_git_stderr(stderr) + assert result.kind == GitErrorKind.AUTH + + def test_not_found_beats_timeout(self) -> None: + stderr = ( + "Repository not found\n" + "Connection timed out" + ) + result = translate_git_stderr(stderr) + assert result.kind == GitErrorKind.NOT_FOUND + + +# --------------------------------------------------------------------------- +# Raw stderr truncation +# --------------------------------------------------------------------------- + + +class TestRawTruncation: + """Raw stderr is truncated to <= 500 chars.""" + + def test_499_chars_not_truncated(self) -> None: + stderr = "x" * 499 + result = translate_git_stderr(stderr) + assert result.raw == stderr + assert len(result.raw) == 499 + + def test_500_chars_not_truncated(self) -> None: + stderr = "y" * 500 + result = translate_git_stderr(stderr) + assert result.raw == stderr + assert len(result.raw) == 500 + + def test_501_chars_truncated(self) -> None: + stderr = "z" * 501 + result = translate_git_stderr(stderr) + assert result.raw == "z" * 500 + "... (truncated)" + assert len(result.raw) == 500 + len("... (truncated)") + assert result.raw.endswith("... (truncated)") + + def test_long_stderr_truncated(self) -> None: + stderr = "a" * 2000 + result = translate_git_stderr(stderr) + assert result.raw.startswith("a" * 500) + assert result.raw.endswith("... (truncated)") + + +# --------------------------------------------------------------------------- +# Summary length cap (80 chars) +# --------------------------------------------------------------------------- + + +class TestSummaryLengthCap: + """Summary is capped at 80 characters.""" + + def test_short_operation_under_cap(self) -> None: + result = translate_git_stderr("kaboom", operation="push") + assert len(result.summary) <= 80 + + def test_long_operation_capped(self) -> None: + long_op = "a-very-long-operation-name-that-goes-on-and-on-" * 3 + result = translate_git_stderr("kaboom", operation=long_op) + assert len(result.summary) <= 80 + assert result.summary.endswith("...") + + def test_all_kinds_respect_cap(self) -> None: + long_op = "x" * 200 + for stderr, expected_kind in [ + ("fatal: Authentication failed", GitErrorKind.AUTH), + ("Repository not found", GitErrorKind.NOT_FOUND), + ("Connection timed out", GitErrorKind.TIMEOUT), + ("kaboom", GitErrorKind.UNKNOWN), + ]: + result = translate_git_stderr( + stderr, operation=long_op, exit_code=128 + ) + assert result.kind == expected_kind + assert len(result.summary) <= 80, ( + f"{expected_kind}: summary is {len(result.summary)} chars" + ) + + +# --------------------------------------------------------------------------- +# Dataclass properties +# --------------------------------------------------------------------------- + + +class TestTranslatedGitErrorDataclass: + """TranslatedGitError is a frozen dataclass.""" + + def test_frozen(self) -> None: + result = translate_git_stderr("kaboom") + with pytest.raises(AttributeError): + result.kind = GitErrorKind.AUTH # type: ignore[misc] + + def test_fields(self) -> None: + result = translate_git_stderr( + "fatal: Authentication failed", + exit_code=128, + operation="push", + remote="acme/tools", + ) + assert isinstance(result.kind, GitErrorKind) + assert isinstance(result.summary, str) + assert isinstance(result.hint, str) + assert isinstance(result.raw, str) + + +# --------------------------------------------------------------------------- +# ASCII-only enforcement +# --------------------------------------------------------------------------- + + +class TestAsciiOnly: + """Every string field must be pure ASCII.""" + + @pytest.mark.parametrize( + "stderr", + [ + "fatal: Authentication failed", + "Repository not found", + "Connection timed out", + "unknown error", + "", + ], + ids=["auth", "not-found", "timeout", "unknown", "empty"], + ) + def test_all_fields_are_ascii(self, stderr: str) -> None: + result = translate_git_stderr( + stderr, operation="push", remote="acme/tools", exit_code=1 + ) + for field_name in ("summary", "hint", "raw"): + value = getattr(result, field_name) + value.encode("ascii") # raises UnicodeEncodeError if non-ASCII + + +# --------------------------------------------------------------------------- +# GitErrorKind enum values +# --------------------------------------------------------------------------- + + +class TestGitErrorKindEnum: + """Enum has exactly the expected members and values.""" + + def test_members(self) -> None: + assert set(GitErrorKind) == { + GitErrorKind.AUTH, + GitErrorKind.NOT_FOUND, + GitErrorKind.TIMEOUT, + GitErrorKind.UNKNOWN, + } + + def test_values(self) -> None: + assert GitErrorKind.AUTH.value == "auth" + assert GitErrorKind.NOT_FOUND.value == "not_found" + assert GitErrorKind.TIMEOUT.value == "timeout" + assert GitErrorKind.UNKNOWN.value == "unknown" + + +# --------------------------------------------------------------------------- +# Default parameter values +# --------------------------------------------------------------------------- + + +class TestDefaults: + """Default parameter values are applied correctly.""" + + def test_default_operation(self) -> None: + result = translate_git_stderr("kaboom") + assert "git operation" in result.summary + + def test_default_exit_code_none(self) -> None: + result = translate_git_stderr("kaboom") + assert "exit" not in result.summary + + def test_default_remote_none_in_not_found_hint(self) -> None: + result = translate_git_stderr("Repository not found") + assert "the remote" in result.hint + assert "'" not in result.hint or "the remote" in result.hint diff --git a/tests/unit/marketplace/test_git_utils.py b/tests/unit/marketplace/test_git_utils.py new file mode 100644 index 000000000..7605993be --- /dev/null +++ b/tests/unit/marketplace/test_git_utils.py @@ -0,0 +1,65 @@ +"""Tests for _git_utils.py -- shared git token redaction.""" + +from __future__ import annotations + +import pytest + +from apm_cli.marketplace._git_utils import redact_token + + +class TestRedactToken: + """Tests for the improved ``redact_token()`` function.""" + + def test_https_token_redacted(self) -> None: + """Standard ``https://TOKEN@host`` pattern is redacted.""" + text = "fatal: auth failed for https://x-access-token:ghp_abc123@github.com/acme/tools" + result = redact_token(text) + assert "ghp_abc123" not in result + assert "https://***@" in result + + def test_http_token_redacted(self) -> None: + """Plain ``http://TOKEN@host`` pattern is now also redacted.""" + text = "error: http://oauth2:gho_SECRET@github.com/acme/repo.git" + result = redact_token(text) + assert "gho_SECRET" not in result + assert "***@" in result + + def test_query_param_token_redacted(self) -> None: + """``?token=VALUE`` query parameter is redacted, preserving ``?``.""" + text = "https://github.com/archive?token=abc123" + result = redact_token(text) + assert "abc123" not in result + assert "?token=***" in result + + def test_ampersand_query_param_redacted(self) -> None: + """``&token=VALUE`` query parameter is redacted, preserving ``&``.""" + text = "https://host/path?ref=main&token=secret42" + result = redact_token(text) + assert "secret42" not in result + assert "&token=***" in result + + def test_no_token_passthrough(self) -> None: + """Text without any token patterns is returned unchanged.""" + text = "fatal: repository not found" + assert redact_token(text) == text + + def test_multiple_tokens_in_one_string(self) -> None: + """All token occurrences in a single string are redacted.""" + text = ( + "https://user:pass1@github.com/a/b " + "https://user:pass2@github.com/c/d" + ) + result = redact_token(text) + assert "pass1" not in result + assert "pass2" not in result + + def test_mixed_patterns(self) -> None: + """A string containing both URL-auth and query-param tokens.""" + text = ( + "clone https://tok@host/repo " + "archive at https://host/file?token=xyz" + ) + result = redact_token(text) + assert "tok@" not in result + assert "xyz" not in result + assert "***" in result diff --git a/tests/unit/marketplace/test_init_template.py b/tests/unit/marketplace/test_init_template.py new file mode 100644 index 000000000..084286292 --- /dev/null +++ b/tests/unit/marketplace/test_init_template.py @@ -0,0 +1,80 @@ +"""Tests for ``apm_cli.marketplace.init_template``.""" + +from __future__ import annotations + +from pathlib import Path +import tempfile + +import pytest +import yaml + +from apm_cli.marketplace.init_template import render_marketplace_yml_template +from apm_cli.marketplace.yml_schema import load_marketplace_yml + + +# --------------------------------------------------------------------------- +# Basic contract +# --------------------------------------------------------------------------- + + +class TestRenderTemplate: + def test_returns_non_empty_string(self): + result = render_marketplace_yml_template() + assert isinstance(result, str) + assert len(result) > 0 + + def test_parseable_by_yaml_safe_load(self): + text = render_marketplace_yml_template() + data = yaml.safe_load(text) + assert isinstance(data, dict) + + def test_roundtrips_through_load_marketplace_yml(self, tmp_path): + text = render_marketplace_yml_template() + fp = tmp_path / "marketplace.yml" + fp.write_text(text, encoding="utf-8") + yml = load_marketplace_yml(fp) + assert yml.name == "my-marketplace" + + def test_contains_required_top_level_keys(self): + text = render_marketplace_yml_template() + data = yaml.safe_load(text) + for key in ("name", "description", "version", "owner", "packages"): + assert key in data, f"Missing top-level key: {key}" + + def test_owner_has_name(self): + text = render_marketplace_yml_template() + data = yaml.safe_load(text) + assert "name" in data["owner"] + + def test_packages_is_list(self): + text = render_marketplace_yml_template() + data = yaml.safe_load(text) + assert isinstance(data["packages"], list) + assert len(data["packages"]) >= 1 + + +# --------------------------------------------------------------------------- +# Content safety +# --------------------------------------------------------------------------- + + +class TestTemplateSafety: + def test_pure_ascii(self): + text = render_marketplace_yml_template() + text.encode("ascii") # raises UnicodeEncodeError if non-ASCII + + def test_no_epam_references(self): + text = render_marketplace_yml_template().lower() + assert "epam" not in text + assert "bookstore" not in text + assert "agent-forge" not in text + + def test_contains_acme_org(self): + text = render_marketplace_yml_template() + assert "acme-org" in text + + def test_contains_build_section(self): + text = render_marketplace_yml_template() + data = yaml.safe_load(text) + assert "build" in data + assert "tagPattern" in data["build"] diff --git a/tests/unit/marketplace/test_io.py b/tests/unit/marketplace/test_io.py new file mode 100644 index 000000000..441c1a26c --- /dev/null +++ b/tests/unit/marketplace/test_io.py @@ -0,0 +1,40 @@ +"""Tests for _io.py -- shared atomic write helper.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from apm_cli.marketplace._io import atomic_write + + +class TestAtomicWrite: + """Tests for the shared ``atomic_write()`` function.""" + + def test_creates_file_with_correct_content(self, tmp_path: Path) -> None: + """A new file is created with the expected content.""" + path = tmp_path / "output.txt" + atomic_write(path, "hello world\n") + assert path.read_text(encoding="utf-8") == "hello world\n" + + def test_no_tmp_file_remains(self, tmp_path: Path) -> None: + """The temporary file is cleaned up after a successful write.""" + path = tmp_path / "output.txt" + atomic_write(path, "data") + tmp_file = path.with_suffix(path.suffix + ".tmp") + assert not tmp_file.exists() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + """An existing file is replaced with the new content.""" + path = tmp_path / "output.txt" + path.write_text("old content", encoding="utf-8") + atomic_write(path, "new content") + assert path.read_text(encoding="utf-8") == "new content" + + def test_preserves_unicode(self, tmp_path: Path) -> None: + """Non-ASCII content round-trips correctly.""" + path = tmp_path / "output.txt" + content = '{"name": "caf\\u00e9"}\n' + atomic_write(path, content) + assert path.read_text(encoding="utf-8") == content diff --git a/tests/unit/marketplace/test_marketplace_commands.py b/tests/unit/marketplace/test_marketplace_commands.py index 0bf1b7645..bd96bd331 100644 --- a/tests/unit/marketplace/test_marketplace_commands.py +++ b/tests/unit/marketplace/test_marketplace_commands.py @@ -196,8 +196,9 @@ def test_search_empty_marketplace(self, runner): @patch("apm_cli.marketplace.registry.get_marketplace_by_name") def test_search_unknown_marketplace(self, mock_get, runner): from apm_cli.commands.marketplace import search + from apm_cli.marketplace.errors import MarketplaceNotFoundError - mock_get.side_effect = Exception("not found") + mock_get.side_effect = MarketplaceNotFoundError("nonexistent") result = runner.invoke(search, ["security@nonexistent"]) assert result.exit_code != 0 assert "not registered" in result.output.lower() diff --git a/tests/unit/marketplace/test_pr_integration.py b/tests/unit/marketplace/test_pr_integration.py new file mode 100644 index 000000000..4b23ddbad --- /dev/null +++ b/tests/unit/marketplace/test_pr_integration.py @@ -0,0 +1,1030 @@ +"""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 new file mode 100644 index 000000000..8bcf0fb46 --- /dev/null +++ b/tests/unit/marketplace/test_publisher.py @@ -0,0 +1,1588 @@ +"""Tests for publisher.py -- MarketplacePublisher, PublishState, data model.""" + +from __future__ import annotations + +import json +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\n" + "APM-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\n" + "APM-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" diff --git a/tests/unit/marketplace/test_ref_resolver.py b/tests/unit/marketplace/test_ref_resolver.py new file mode 100644 index 000000000..370bf6a5e --- /dev/null +++ b/tests/unit/marketplace/test_ref_resolver.py @@ -0,0 +1,502 @@ +"""Tests for ref_resolver.py -- RefCache, RefResolver, ls-remote parsing.""" + +from __future__ import annotations + +import subprocess +import time +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.marketplace.errors import GitLsRemoteError, OfflineMissError +from apm_cli.marketplace.ref_resolver import ( + RefCache, + RefResolver, + RemoteRef, + _parse_ls_remote_output, + _redact_token, +) + + +# --------------------------------------------------------------------------- +# _parse_ls_remote_output +# --------------------------------------------------------------------------- + + +class TestParseLsRemoteOutput: + """Tests for parsing raw ls-remote stdout.""" + + def test_empty_output(self) -> None: + assert _parse_ls_remote_output("") == [] + + def test_single_tag(self) -> None: + line = "abcd23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0" + refs = _parse_ls_remote_output(line) + assert len(refs) == 1 + assert refs[0].name == "refs/tags/v1.0.0" + assert refs[0].sha == "abcd23456789abcdef1234567890abcdef123456" + + def test_multiple_refs(self) -> None: + output = ( + "aaaa23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0\n" + "bbbb23456789abcdef1234567890abcdef123456\trefs/tags/v2.0.0\n" + "cccc23456789abcdef1234567890abcdef123456\trefs/heads/main\n" + ) + refs = _parse_ls_remote_output(output) + assert len(refs) == 3 + + def test_peeled_tag_skipped(self) -> None: + output = ( + "aaaa23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0\n" + "bbbb23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0^{}\n" + ) + refs = _parse_ls_remote_output(output) + assert len(refs) == 1 + assert refs[0].name == "refs/tags/v1.0.0" + + def test_invalid_sha_skipped(self) -> None: + output = "not-a-sha\trefs/tags/v1.0.0\n" + refs = _parse_ls_remote_output(output) + assert len(refs) == 0 + + def test_blank_lines_skipped(self) -> None: + output = ( + "\n" + "aaaa23456789abcdef1234567890abcdef123456\trefs/tags/v1.0.0\n" + "\n" + ) + refs = _parse_ls_remote_output(output) + assert len(refs) == 1 + + def test_no_tab_separator_skipped(self) -> None: + output = "aaaa23456789abcdef1234567890abcdef123456 refs/tags/v1.0.0\n" + refs = _parse_ls_remote_output(output) + assert len(refs) == 0 + + def test_whitespace_trimmed(self) -> None: + output = " aaaa23456789abcdef1234567890abcdef123456\t refs/tags/v1.0.0 \n" + refs = _parse_ls_remote_output(output) + assert len(refs) == 1 + assert refs[0].name == "refs/tags/v1.0.0" + + +# --------------------------------------------------------------------------- +# _redact_token +# --------------------------------------------------------------------------- + + +class TestRedactToken: + """Tests for token redaction in error messages.""" + + def test_redact_access_token(self) -> None: + text = "fatal: auth failed for https://x-access-token:ghp_abc123@github.com/acme/tools" + result = _redact_token(text) + assert "ghp_abc123" not in result + assert "***" in result + + def test_redact_oauth_token(self) -> None: + text = "https://oauth2:gho_secret@github.com/acme/repo.git" + result = _redact_token(text) + assert "gho_secret" not in result + + def test_no_token_unchanged(self) -> None: + text = "fatal: repository not found" + assert _redact_token(text) == text + + def test_multiple_tokens_redacted(self) -> None: + text = ( + "https://user:pass1@github.com/a/b " + "https://user:pass2@github.com/c/d" + ) + result = _redact_token(text) + assert "pass1" not in result + assert "pass2" not in result + + +# --------------------------------------------------------------------------- +# RefCache +# --------------------------------------------------------------------------- + + +class TestRefCache: + """Tests for in-memory ref cache.""" + + def test_put_and_get(self) -> None: + cache = RefCache() + refs = [RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40)] + cache.put("acme/tools", refs) + result = cache.get("acme/tools") + assert result is not None + assert len(result) == 1 + assert result[0].name == "refs/tags/v1.0.0" + + def test_miss_returns_none(self) -> None: + cache = RefCache() + assert cache.get("acme/unknown") is None + + def test_expiry(self) -> None: + cache = RefCache(ttl_seconds=0.01) + refs = [RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40)] + cache.put("acme/tools", refs) + time.sleep(0.02) + assert cache.get("acme/tools") is None + + def test_not_expired_within_ttl(self) -> None: + cache = RefCache(ttl_seconds=60.0) + refs = [RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40)] + cache.put("acme/tools", refs) + result = cache.get("acme/tools") + assert result is not None + + def test_clear(self) -> None: + cache = RefCache() + cache.put("acme/tools", []) + cache.clear() + assert len(cache) == 0 + assert cache.get("acme/tools") is None + + def test_get_returns_copy(self) -> None: + """Mutating returned list does not affect cache.""" + cache = RefCache() + refs = [RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40)] + cache.put("acme/tools", refs) + result = cache.get("acme/tools") + assert result is not None + result.clear() + assert len(cache.get("acme/tools")) == 1 # type: ignore[arg-type] + + def test_len(self) -> None: + cache = RefCache() + assert len(cache) == 0 + cache.put("acme/a", []) + cache.put("acme/b", []) + assert len(cache) == 2 + + +# --------------------------------------------------------------------------- +# RefResolver +# --------------------------------------------------------------------------- + + +_SHA_A = "a" * 40 +_SHA_B = "b" * 40 +_SHA_C = "c" * 40 + +_MOCK_LS_REMOTE_OUTPUT = ( + f"{_SHA_A}\trefs/tags/v1.0.0\n" + f"{_SHA_B}\trefs/tags/v2.0.0\n" + f"{_SHA_C}\trefs/heads/main\n" +) + + +def _make_completed(stdout: str = "", stderr: str = "", returncode: int = 0): + """Create a mock subprocess.CompletedProcess.""" + return subprocess.CompletedProcess( + args=["git", "ls-remote"], + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) + + +class TestRefResolver: + """Tests for RefResolver with mocked subprocess.""" + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_list_remote_refs_success(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout=_MOCK_LS_REMOTE_OUTPUT) + resolver = RefResolver(timeout_seconds=5.0) + refs = resolver.list_remote_refs("acme/tools") + assert len(refs) == 3 + assert refs[0].name == "refs/tags/v1.0.0" + assert refs[0].sha == _SHA_A + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_cache_hit(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout=_MOCK_LS_REMOTE_OUTPUT) + resolver = RefResolver(timeout_seconds=5.0) + resolver.list_remote_refs("acme/tools") + resolver.list_remote_refs("acme/tools") + # Should only call subprocess once (cache hit) + assert mock_run.call_count == 1 + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_different_remotes_separate_calls(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout=_MOCK_LS_REMOTE_OUTPUT) + resolver = RefResolver(timeout_seconds=5.0) + resolver.list_remote_refs("acme/tools") + resolver.list_remote_refs("acme/other") + assert mock_run.call_count == 2 + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_git_failure_raises(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + returncode=128, + stderr="fatal: repository 'https://github.com/acme/gone.git' not found", + ) + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError): + resolver.list_remote_refs("acme/gone") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_timeout_raises(self, mock_run: MagicMock) -> None: + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5.0) + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError, match="timed out"): + resolver.list_remote_refs("acme/slow") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_os_error_raises(self, mock_run: MagicMock) -> None: + mock_run.side_effect = OSError("git not found") + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError, match="git is installed"): + resolver.list_remote_refs("acme/tools") + resolver.close() + + def test_offline_mode_miss(self) -> None: + resolver = RefResolver(timeout_seconds=5.0, offline=True) + with pytest.raises(OfflineMissError): + resolver.list_remote_refs("acme/tools") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_offline_mode_cache_hit(self, mock_run: MagicMock) -> None: + """Pre-populate cache, then switch to offline.""" + mock_run.return_value = _make_completed(stdout=_MOCK_LS_REMOTE_OUTPUT) + resolver = RefResolver(timeout_seconds=5.0, offline=False) + resolver.list_remote_refs("acme/tools") + + # Now switch to offline via a new resolver sharing the cache + resolver._offline = True + refs = resolver.list_remote_refs("acme/tools") + assert len(refs) == 3 + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_token_redacted_in_error(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + returncode=128, + stderr="fatal: auth failed for https://x-access-token:ghp_secret@github.com/acme/priv.git", + ) + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError) as exc_info: + resolver.list_remote_refs("acme/priv") + assert "ghp_secret" not in str(exc_info.value) + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_stderr_translator_disabled(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + returncode=128, + stderr="some error", + ) + resolver = RefResolver( + timeout_seconds=5.0, + stderr_translator_enabled=False, + ) + with pytest.raises(GitLsRemoteError): + resolver.list_remote_refs("acme/tools") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_empty_repo(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout="") + resolver = RefResolver(timeout_seconds=5.0) + refs = resolver.list_remote_refs("acme/empty") + assert refs == [] + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_correct_command_args(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout="") + resolver = RefResolver(timeout_seconds=7.5) + resolver.list_remote_refs("acme/tools") + args, kwargs = mock_run.call_args + assert args[0] == [ + "git", "ls-remote", "--tags", "--heads", + "https://github.com/acme/tools.git", + ] + assert kwargs["timeout"] == 7.5 + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + resolver.close() + + def test_close_clears_cache(self) -> None: + resolver = RefResolver(timeout_seconds=5.0) + resolver.cache.put("acme/tools", []) + assert len(resolver.cache) == 1 + resolver.close() + assert len(resolver.cache) == 0 + + +# --------------------------------------------------------------------------- +# RefResolver.resolve_ref_sha +# --------------------------------------------------------------------------- + + +class TestResolveRefSha: + """Tests for single-ref resolution via resolve_ref_sha.""" + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_happy_path_returns_sha(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + stdout=f"{_SHA_A}\tHEAD\n", + ) + resolver = RefResolver(timeout_seconds=5.0) + sha = resolver.resolve_ref_sha("acme/tools") + assert sha == _SHA_A + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_resolves_specific_ref(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + stdout=f"{_SHA_B}\trefs/heads/main\n", + ) + resolver = RefResolver(timeout_seconds=5.0) + sha = resolver.resolve_ref_sha("acme/tools", ref="main") + assert sha == _SHA_B + # Verify command uses the ref directly (no --tags --heads). + args, kwargs = mock_run.call_args + assert args[0] == [ + "git", "ls-remote", + "https://github.com/acme/tools.git", + "main", + ] + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_ref_not_found_raises(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout="") + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError, match="not found"): + resolver.resolve_ref_sha("acme/tools", ref="nonexistent") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_network_failure_raises(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + returncode=128, + stderr="fatal: unable to access", + ) + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError): + resolver.resolve_ref_sha("acme/tools") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_timeout_raises(self, mock_run: MagicMock) -> None: + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5.0) + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError, match="timed out"): + resolver.resolve_ref_sha("acme/tools") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_os_error_raises(self, mock_run: MagicMock) -> None: + mock_run.side_effect = OSError("git not found") + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError, match="git is installed"): + resolver.resolve_ref_sha("acme/tools") + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_does_not_use_cache(self, mock_run: MagicMock) -> None: + """resolve_ref_sha never reads from or writes to the cache.""" + mock_run.return_value = _make_completed( + stdout=f"{_SHA_A}\tHEAD\n", + ) + resolver = RefResolver(timeout_seconds=5.0) + resolver.resolve_ref_sha("acme/tools") + assert len(resolver.cache) == 0 # Not cached. + resolver.resolve_ref_sha("acme/tools") + assert mock_run.call_count == 2 # Called twice (no cache hit). + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_security_env_vars(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + stdout=f"{_SHA_A}\tHEAD\n", + ) + resolver = RefResolver(timeout_seconds=5.0) + resolver.resolve_ref_sha("acme/tools") + _, kwargs = mock_run.call_args + env = kwargs.get("env", {}) + assert env.get("GIT_TERMINAL_PROMPT") == "0" + assert env.get("GIT_ASKPASS") == "echo" + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_token_redacted_in_error(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed( + returncode=128, + stderr="fatal: auth failed for https://x-access-token:ghp_secret@github.com/acme/priv.git", + ) + resolver = RefResolver(timeout_seconds=5.0) + with pytest.raises(GitLsRemoteError) as exc_info: + resolver.resolve_ref_sha("acme/priv") + assert "ghp_secret" not in str(exc_info.value) + resolver.close() + + +# --------------------------------------------------------------------------- +# RemoteRef frozen dataclass +# --------------------------------------------------------------------------- + + +class TestRemoteRef: + """Basic dataclass tests.""" + + def test_frozen(self) -> None: + ref = RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40) + with pytest.raises(AttributeError): + ref.name = "other" # type: ignore[misc] + + def test_equality(self) -> None: + a = RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40) + b = RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40) + assert a == b + + def test_inequality(self) -> None: + a = RemoteRef(name="refs/tags/v1.0.0", sha="a" * 40) + b = RemoteRef(name="refs/tags/v2.0.0", sha="b" * 40) + assert a != b + + +# --------------------------------------------------------------------------- +# S3: GIT_TERMINAL_PROMPT suppression +# --------------------------------------------------------------------------- + + +class TestGitTerminalPromptSuppression: + """Subprocess calls must include GIT_TERMINAL_PROMPT=0 to prevent hangs.""" + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_env_includes_git_terminal_prompt(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout=_MOCK_LS_REMOTE_OUTPUT) + resolver = RefResolver(timeout_seconds=5.0) + resolver.list_remote_refs("acme/tools") + + _, kwargs = mock_run.call_args + env = kwargs.get("env", {}) + assert env.get("GIT_TERMINAL_PROMPT") == "0", ( + "subprocess.run must pass GIT_TERMINAL_PROMPT=0 in env" + ) + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_env_includes_git_askpass(self, mock_run: MagicMock) -> None: + mock_run.return_value = _make_completed(stdout=_MOCK_LS_REMOTE_OUTPUT) + resolver = RefResolver(timeout_seconds=5.0) + resolver.list_remote_refs("acme/tools") + + _, kwargs = mock_run.call_args + env = kwargs.get("env", {}) + assert env.get("GIT_ASKPASS") == "echo", ( + "subprocess.run must pass GIT_ASKPASS=echo in env" + ) + resolver.close() diff --git a/tests/unit/marketplace/test_semver.py b/tests/unit/marketplace/test_semver.py new file mode 100644 index 000000000..e9acb8905 --- /dev/null +++ b/tests/unit/marketplace/test_semver.py @@ -0,0 +1,283 @@ +"""Tests for semver.py -- SemVer parsing, comparison, and range matching.""" + +from __future__ import annotations + +import pytest + +from apm_cli.marketplace.semver import SemVer, parse_semver, satisfies_range + + +# --------------------------------------------------------------------------- +# parse_semver +# --------------------------------------------------------------------------- + + +class TestParseSemver: + """Tests for parse_semver().""" + + def test_plain_version(self) -> None: + sv = parse_semver("1.2.3") + assert sv is not None + assert sv.major == 1 + assert sv.minor == 2 + assert sv.patch == 3 + assert sv.prerelease == "" + assert sv.build_meta == "" + + def test_prerelease(self) -> None: + sv = parse_semver("1.0.0-alpha.1") + assert sv is not None + assert sv.prerelease == "alpha.1" + assert sv.is_prerelease + + def test_build_metadata(self) -> None: + sv = parse_semver("1.0.0+build.42") + assert sv is not None + assert sv.build_meta == "build.42" + assert not sv.is_prerelease + + def test_prerelease_and_build(self) -> None: + sv = parse_semver("1.0.0-rc.1+build.5") + assert sv is not None + assert sv.prerelease == "rc.1" + assert sv.build_meta == "build.5" + assert sv.is_prerelease + + def test_zero_version(self) -> None: + sv = parse_semver("0.0.0") + assert sv is not None + assert sv.major == 0 + assert sv.minor == 0 + assert sv.patch == 0 + + def test_large_numbers(self) -> None: + sv = parse_semver("999.888.777") + assert sv is not None + assert sv.major == 999 + assert sv.minor == 888 + assert sv.patch == 777 + + def test_invalid_not_a_version(self) -> None: + assert parse_semver("not-a-version") is None + + def test_invalid_two_components(self) -> None: + assert parse_semver("1.2") is None + + def test_invalid_empty_string(self) -> None: + assert parse_semver("") is None + + def test_invalid_leading_v(self) -> None: + # parse_semver requires raw version, no "v" prefix + assert parse_semver("v1.2.3") is None + + def test_invalid_trailing_text(self) -> None: + assert parse_semver("1.2.3 extra") is None + + def test_prerelease_with_hyphens(self) -> None: + sv = parse_semver("1.0.0-alpha-beta") + assert sv is not None + assert sv.prerelease == "alpha-beta" + + +# --------------------------------------------------------------------------- +# SemVer comparison +# --------------------------------------------------------------------------- + + +class TestSemVerComparison: + """Tests for SemVer comparison operators.""" + + def test_major_ordering(self) -> None: + assert parse_semver("1.0.0") < parse_semver("2.0.0") # type: ignore[operator] + + def test_minor_ordering(self) -> None: + assert parse_semver("1.0.0") < parse_semver("1.1.0") # type: ignore[operator] + + def test_patch_ordering(self) -> None: + assert parse_semver("1.0.0") < parse_semver("1.0.1") # type: ignore[operator] + + def test_prerelease_before_release(self) -> None: + assert parse_semver("1.0.0-alpha") < parse_semver("1.0.0") # type: ignore[operator] + + def test_prerelease_alphabetical(self) -> None: + assert parse_semver("1.0.0-alpha") < parse_semver("1.0.0-beta") # type: ignore[operator] + + def test_equality(self) -> None: + assert parse_semver("1.0.0") == parse_semver("1.0.0") + + def test_equality_with_prerelease(self) -> None: + assert parse_semver("1.0.0-alpha") == parse_semver("1.0.0-alpha") + + def test_not_equal_different_prerelease(self) -> None: + assert parse_semver("1.0.0-alpha") != parse_semver("1.0.0-beta") + + def test_numeric_prerelease_sorting(self) -> None: + # Numeric identifiers sort numerically, not lexicographically + assert parse_semver("1.0.0-2") < parse_semver("1.0.0-10") # type: ignore[operator] + + def test_numeric_before_alpha_prerelease(self) -> None: + # Numeric identifiers have lower precedence than alphanumeric + assert parse_semver("1.0.0-1") < parse_semver("1.0.0-alpha") # type: ignore[operator] + + def test_build_metadata_ignored_in_equality(self) -> None: + a = parse_semver("1.0.0+build1") + b = parse_semver("1.0.0+build2") + assert a == b + + def test_hashable(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + s = {sv} + assert sv in s + + def test_le(self) -> None: + assert parse_semver("1.0.0") <= parse_semver("1.0.0") # type: ignore[operator] + assert parse_semver("1.0.0") <= parse_semver("2.0.0") # type: ignore[operator] + + def test_ge(self) -> None: + assert parse_semver("2.0.0") >= parse_semver("1.0.0") # type: ignore[operator] + assert parse_semver("1.0.0") >= parse_semver("1.0.0") # type: ignore[operator] + + def test_gt(self) -> None: + assert parse_semver("2.0.0") > parse_semver("1.0.0") # type: ignore[operator] + + def test_comparison_with_non_semver_returns_not_implemented(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert sv.__lt__("string") is NotImplemented + assert sv.__le__("string") is NotImplemented + assert sv.__gt__("string") is NotImplemented + assert sv.__ge__("string") is NotImplemented + assert sv.__eq__("string") is NotImplemented + + +# --------------------------------------------------------------------------- +# satisfies_range +# --------------------------------------------------------------------------- + + +class TestSatisfiesRange: + """Tests for satisfies_range().""" + + # -- Exact match -- + + def test_exact_match(self) -> None: + sv = parse_semver("1.2.3") + assert sv is not None + assert satisfies_range(sv, "1.2.3") + assert not satisfies_range(sv, "1.2.4") + + def test_exact_prerelease(self) -> None: + sv = parse_semver("1.0.0-alpha") + assert sv is not None + assert satisfies_range(sv, "1.0.0-alpha") + assert not satisfies_range(sv, "1.0.0-beta") + + # -- Caret ranges -- + + def test_caret_major_nonzero(self) -> None: + assert satisfies_range(parse_semver("1.2.3"), "^1.2.3") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.9.9"), "^1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("2.0.0"), "^1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.2.2"), "^1.2.3") # type: ignore[arg-type] + + def test_caret_zero_major(self) -> None: + assert satisfies_range(parse_semver("0.2.3"), "^0.2.3") # type: ignore[arg-type] + assert satisfies_range(parse_semver("0.2.9"), "^0.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.3.0"), "^0.2.3") # type: ignore[arg-type] + + def test_caret_zero_zero(self) -> None: + assert satisfies_range(parse_semver("0.0.3"), "^0.0.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.0.4"), "^0.0.3") # type: ignore[arg-type] + + def test_caret_invalid_spec(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, "^garbage") + + # -- Tilde ranges -- + + def test_tilde(self) -> None: + assert satisfies_range(parse_semver("1.2.3"), "~1.2.3") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.2.9"), "~1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.3.0"), "~1.2.3") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.2.2"), "~1.2.3") # type: ignore[arg-type] + + def test_tilde_invalid_spec(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, "~garbage") + + # -- Comparison operators -- + + def test_gte(self) -> None: + assert satisfies_range(parse_semver("2.0.0"), ">=1.0.0") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.0.0"), ">=1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.9.0"), ">=1.0.0") # type: ignore[arg-type] + + def test_gt(self) -> None: + assert satisfies_range(parse_semver("2.0.0"), ">1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.0.0"), ">1.0.0") # type: ignore[arg-type] + + def test_lte(self) -> None: + assert satisfies_range(parse_semver("1.0.0"), "<=1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.0.1"), "<=1.0.0") # type: ignore[arg-type] + + def test_lt(self) -> None: + assert satisfies_range(parse_semver("0.9.0"), "<1.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.0.0"), "<1.0.0") # type: ignore[arg-type] + + def test_gt_invalid_spec(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, ">garbage") + + def test_gte_invalid_spec(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, ">=garbage") + + def test_lt_invalid_spec(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, " None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, "<=garbage") + + # -- Wildcard -- + + def test_wildcard_x(self) -> None: + assert satisfies_range(parse_semver("1.2.0"), "1.2.x") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.2.9"), "1.2.x") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("1.3.0"), "1.2.x") # type: ignore[arg-type] + + def test_wildcard_star(self) -> None: + assert satisfies_range(parse_semver("1.2.0"), "1.2.*") # type: ignore[arg-type] + assert satisfies_range(parse_semver("1.2.5"), "1.2.*") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("2.2.0"), "1.2.*") # type: ignore[arg-type] + + def test_wildcard_uppercase_x(self) -> None: + assert satisfies_range(parse_semver("3.1.7"), "3.1.X") # type: ignore[arg-type] + + # -- Combined (AND) -- + + def test_and_range(self) -> None: + assert satisfies_range(parse_semver("1.5.0"), ">=1.0.0 <2.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("2.0.0"), ">=1.0.0 <2.0.0") # type: ignore[arg-type] + assert not satisfies_range(parse_semver("0.9.0"), ">=1.0.0 <2.0.0") # type: ignore[arg-type] + + # -- Empty range -- + + def test_empty_range_matches_all(self) -> None: + assert satisfies_range(parse_semver("1.0.0"), "") # type: ignore[arg-type] + assert satisfies_range(parse_semver("99.0.0"), " ") # type: ignore[arg-type] + + # -- Invalid exact spec -- + + def test_invalid_exact_spec(self) -> None: + sv = parse_semver("1.0.0") + assert sv is not None + assert not satisfies_range(sv, "garbage") diff --git a/tests/unit/marketplace/test_tag_pattern.py b/tests/unit/marketplace/test_tag_pattern.py new file mode 100644 index 000000000..d68e59b33 --- /dev/null +++ b/tests/unit/marketplace/test_tag_pattern.py @@ -0,0 +1,222 @@ +"""Tests for tag_pattern.py -- render_tag and build_tag_regex.""" + +from __future__ import annotations + +import re + +import pytest + +from apm_cli.marketplace.tag_pattern import build_tag_regex, render_tag + + +# --------------------------------------------------------------------------- +# render_tag +# --------------------------------------------------------------------------- + + +class TestRenderTag: + """Tests for render_tag placeholder expansion.""" + + def test_version_only(self) -> None: + assert render_tag("v{version}", name="pkg", version="1.2.3") == "v1.2.3" + + def test_name_and_version(self) -> None: + result = render_tag("{name}-v{version}", name="my-tool", version="0.1.0") + assert result == "my-tool-v0.1.0" + + def test_bare_version(self) -> None: + assert render_tag("{version}", name="x", version="2.0.0") == "2.0.0" + + def test_release_prefix(self) -> None: + result = render_tag("release-{version}", name="x", version="3.0.0") + assert result == "release-3.0.0" + + def test_name_only_placeholder(self) -> None: + result = render_tag("{name}-latest", name="tool", version="1.0.0") + assert result == "tool-latest" + + def test_multiple_version_placeholders(self) -> None: + result = render_tag("v{version}-{version}", name="x", version="1.0.0") + assert result == "v1.0.0-1.0.0" + + def test_no_placeholders(self) -> None: + result = render_tag("fixed-tag", name="x", version="1.0.0") + assert result == "fixed-tag" + + def test_prerelease_version(self) -> None: + result = render_tag("v{version}", name="x", version="1.0.0-alpha.1") + assert result == "v1.0.0-alpha.1" + + def test_version_with_build_metadata(self) -> None: + result = render_tag("v{version}", name="x", version="1.0.0+build.42") + assert result == "v1.0.0+build.42" + + def test_empty_name(self) -> None: + result = render_tag("{name}-v{version}", name="", version="1.0.0") + assert result == "-v1.0.0" + + def test_special_chars_in_name(self) -> None: + result = render_tag("{name}-v{version}", name="my.tool", version="1.0.0") + assert result == "my.tool-v1.0.0" + + def test_complex_pattern(self) -> None: + result = render_tag( + "pkg-{name}/release/{version}", + name="widget", + version="4.5.6", + ) + assert result == "pkg-widget/release/4.5.6" + + +# --------------------------------------------------------------------------- +# build_tag_regex +# --------------------------------------------------------------------------- + + +class TestBuildTagRegex: + """Tests for build_tag_regex pattern compilation.""" + + def test_v_version_pattern(self) -> None: + rx = build_tag_regex("v{version}") + m = rx.match("v1.2.3") + assert m is not None + assert m.group("version") == "1.2.3" + + def test_v_version_no_match_garbage(self) -> None: + rx = build_tag_regex("v{version}") + assert rx.match("v") is None + assert rx.match("vabc") is None + assert rx.match("1.2.3") is None + + def test_bare_version_pattern(self) -> None: + rx = build_tag_regex("{version}") + m = rx.match("1.2.3") + assert m is not None + assert m.group("version") == "1.2.3" + + def test_name_version_pattern(self) -> None: + rx = build_tag_regex("{name}-v{version}") + m = rx.match("my-tool-v1.2.3") + assert m is not None + assert m.group("version") == "1.2.3" + + def test_release_prefix_pattern(self) -> None: + rx = build_tag_regex("release-{version}") + m = rx.match("release-2.0.0") + assert m is not None + assert m.group("version") == "2.0.0" + assert rx.match("v2.0.0") is None + + def test_prerelease_captured(self) -> None: + rx = build_tag_regex("v{version}") + m = rx.match("v1.0.0-alpha.1") + assert m is not None + assert m.group("version") == "1.0.0-alpha.1" + + def test_build_metadata_captured(self) -> None: + rx = build_tag_regex("v{version}") + m = rx.match("v1.0.0+build.42") + assert m is not None + assert m.group("version") == "1.0.0+build.42" + + def test_full_match_only(self) -> None: + """Pattern anchored at start and end -- no partial match.""" + rx = build_tag_regex("v{version}") + # prefix text must not match + assert rx.match("prefix-v1.2.3") is None + # trailing non-semver text must not match + assert rx.match("v1.2.3/foo") is None + + def test_prerelease_and_metadata(self) -> None: + rx = build_tag_regex("v{version}") + m = rx.match("v1.0.0-rc.1+build.5") + assert m is not None + assert m.group("version") == "1.0.0-rc.1+build.5" + + def test_dots_in_pattern_escaped(self) -> None: + """Dots in the literal portion are escaped (not regex wildcards).""" + rx = build_tag_regex("pkg.v{version}") + m = rx.match("pkg.v1.0.0") + assert m is not None + # The dot should not match arbitrary chars + assert rx.match("pkgXv1.0.0") is None + + def test_parens_in_pattern_escaped(self) -> None: + rx = build_tag_regex("(v{version})") + m = rx.match("(v1.0.0)") + assert m is not None + assert m.group("version") == "1.0.0" + + def test_name_wildcard_non_greedy(self) -> None: + rx = build_tag_regex("{name}-v{version}") + m = rx.match("some-pkg-v2.0.0") + assert m is not None + assert m.group("version") == "2.0.0" + + def test_complex_pattern(self) -> None: + rx = build_tag_regex("{name}@{version}") + m = rx.match("tool@3.1.4") + assert m is not None + assert m.group("version") == "3.1.4" + + def test_at_symbol_escaped(self) -> None: + rx = build_tag_regex("{name}@{version}") + # '@' is literal in the pattern + assert rx.match("tool-3.1.4") is None + + def test_multiple_version_not_supported(self) -> None: + """Second {version} placeholder causes regex error (duplicate group). + + This is expected -- patterns should only contain one {version}. + """ + with pytest.raises(re.error): + build_tag_regex("v{version}-{version}") + + def test_no_version_placeholder(self) -> None: + """Pattern with no {version} still compiles (no capture group).""" + rx = build_tag_regex("{name}-latest") + m = rx.match("tool-latest") + assert m is not None + + def test_caret_in_pattern_escaped(self) -> None: + rx = build_tag_regex("^v{version}") + m = rx.match("^v1.0.0") + assert m is not None + + def test_bracket_in_pattern_escaped(self) -> None: + rx = build_tag_regex("[v{version}]") + m = rx.match("[v1.2.3]") + assert m is not None + + def test_plus_in_pattern_escaped(self) -> None: + rx = build_tag_regex("v+{version}") + m = rx.match("v+1.0.0") + assert m is not None + assert rx.match("vv1.0.0") is None # '+' is not a quantifier + + +# --------------------------------------------------------------------------- +# Round-trip: render_tag -> build_tag_regex -> match +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + """Verify render_tag output is matched by build_tag_regex.""" + + @pytest.mark.parametrize( + "pattern,name,version", + [ + ("v{version}", "pkg", "1.2.3"), + ("{version}", "pkg", "0.0.1"), + ("{name}-v{version}", "my-tool", "2.0.0"), + ("release-{version}", "x", "10.20.30"), + ("{name}@{version}", "tool", "1.0.0-beta.1"), + ], + ) + def test_roundtrip(self, pattern: str, name: str, version: str) -> None: + tag = render_tag(pattern, name=name, version=version) + rx = build_tag_regex(pattern) + m = rx.match(tag) + assert m is not None, f"Pattern {pattern!r} did not match rendered tag {tag!r}" + if "{version}" in pattern: + assert m.group("version") == version diff --git a/tests/unit/marketplace/test_version_pins.py b/tests/unit/marketplace/test_version_pins.py index 2614b6458..f40c826d8 100644 --- a/tests/unit/marketplace/test_version_pins.py +++ b/tests/unit/marketplace/test_version_pins.py @@ -37,6 +37,23 @@ def test_load_empty_no_file(self, tmp_path): result = load_ref_pins(pins_dir=str(tmp_path)) assert result == {} + def test_load_missing_no_warning_by_default(self, tmp_path, caplog): + """Missing file does NOT warn when expect_exists is False (default).""" + import logging + with caplog.at_level(logging.WARNING): + result = load_ref_pins(pins_dir=str(tmp_path)) + assert result == {} + assert "expected but missing" not in caplog.text + + def test_load_missing_warns_when_expected(self, tmp_path, caplog): + """Missing file warns when expect_exists=True.""" + import logging + with caplog.at_level(logging.WARNING): + result = load_ref_pins(pins_dir=str(tmp_path), expect_exists=True) + assert result == {} + assert "expected but missing" in caplog.text + assert "ref-swap detection is disabled" in caplog.text + def test_load_corrupt_json(self, tmp_path): """Corrupt JSON returns empty dict without raising.""" path = tmp_path / "version-pins.json" diff --git a/tests/unit/marketplace/test_yml_editor.py b/tests/unit/marketplace/test_yml_editor.py new file mode 100644 index 000000000..476566f96 --- /dev/null +++ b/tests/unit/marketplace/test_yml_editor.py @@ -0,0 +1,337 @@ +"""Tests for ``apm_cli.marketplace.yml_editor`` – round-trip YAML editor.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +import yaml + +from apm_cli.marketplace.errors import MarketplaceYmlError +from apm_cli.marketplace.yml_editor import ( + add_plugin_entry, + remove_plugin_entry, + update_plugin_entry, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_yml(tmp_path: Path, content: str) -> Path: + """Write *content* to ``marketplace.yml`` inside *tmp_path* and return the path.""" + p = tmp_path / "marketplace.yml" + p.write_text(textwrap.dedent(content), encoding="utf-8") + return p + + +_BASIC_YML = """\ +name: test-marketplace +description: Test marketplace +version: 1.0.0 +owner: + name: Test Owner +packages: + - name: existing-package + source: acme/existing-package + version: ">=1.0.0" + description: An existing package +""" + + +# --------------------------------------------------------------------------- +# add_plugin_entry – happy paths +# --------------------------------------------------------------------------- + + +class TestAddPluginHappy: + def test_add_with_version(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + name = add_plugin_entry( + yml, source="acme/new-tool", version=">=2.0.0" + ) + assert name == "new-tool" + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + names = [p["name"] for p in data["packages"]] + assert "new-tool" in names + added = next(p for p in data["packages"] if p["name"] == "new-tool") + assert added["version"] == ">=2.0.0" + assert added["source"] == "acme/new-tool" + + def test_add_with_ref(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + name = add_plugin_entry( + yml, source="acme/pinned-tool", ref="abc123" + ) + assert name == "pinned-tool" + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + added = next( + p for p in data["packages"] if p["name"] == "pinned-tool" + ) + assert added["ref"] == "abc123" + assert "version" not in added + + def test_add_with_all_optional_fields(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + name = add_plugin_entry( + yml, + source="acme/full-tool", + version=">=3.0.0", + subdir="src/plugin", + tag_pattern="v{version}", + tags=["utilities", "testing"], + include_prerelease=True, + ) + assert name == "full-tool" + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + added = next( + p for p in data["packages"] if p["name"] == "full-tool" + ) + assert added["subdir"] == "src/plugin" + assert added["tag_pattern"] == "v{version}" + assert added["tags"] == ["utilities", "testing"] + assert added["include_prerelease"] is True + + def test_name_defaults_to_repo_from_source(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + name = add_plugin_entry( + yml, source="some-org/my-awesome-tool", version=">=1.0.0" + ) + assert name == "my-awesome-tool" + + def test_explicit_name_overrides_source(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + name = add_plugin_entry( + yml, + source="acme/repo-name", + name="custom-name", + version=">=1.0.0", + ) + assert name == "custom-name" + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + added = next( + p for p in data["packages"] if p["name"] == "custom-name" + ) + assert added["source"] == "acme/repo-name" + + def test_reparses_correctly(self, tmp_path): + """The file written by add_plugin_entry re-loads through the schema.""" + from apm_cli.marketplace.yml_schema import load_marketplace_yml + + yml = _write_yml(tmp_path, _BASIC_YML) + add_plugin_entry(yml, source="acme/new-tool", version=">=2.0.0") + parsed = load_marketplace_yml(yml) + names = [p.name for p in parsed.packages] + assert "new-tool" in names + + +# --------------------------------------------------------------------------- +# add_plugin_entry – error paths +# --------------------------------------------------------------------------- + + +class TestAddPluginErrors: + def test_duplicate_name_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="already exists"): + add_plugin_entry( + yml, source="acme/existing-package", version=">=1.0.0" + ) + + def test_duplicate_name_case_insensitive(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="already exists"): + add_plugin_entry( + yml, + source="acme/other", + name="Existing-Package", + version=">=1.0.0", + ) + + def test_both_version_and_ref_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="Cannot specify both"): + add_plugin_entry( + yml, + source="acme/tool", + version=">=1.0.0", + ref="abc123", + ) + + def test_neither_version_nor_ref_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="At least one"): + add_plugin_entry(yml, source="acme/tool") + + def test_invalid_source_no_slash_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="source"): + add_plugin_entry(yml, source="noslash", version=">=1.0.0") + + def test_path_traversal_in_subdir_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError): + add_plugin_entry( + yml, + source="acme/tool", + version=">=1.0.0", + subdir="../etc", + ) + + +# --------------------------------------------------------------------------- +# add_plugin_entry – comment preservation +# --------------------------------------------------------------------------- + + +class TestAddPluginCommentPreservation: + def test_comments_survive_add(self, tmp_path): + content = """\ + # Top-level comment + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + # Package section comment + - name: existing-package + source: acme/existing-package + version: ">=1.0.0" + description: An existing package + """ + yml = _write_yml(tmp_path, content) + add_plugin_entry(yml, source="acme/new-tool", version=">=2.0.0") + text = yml.read_text(encoding="utf-8") + assert "# Top-level comment" in text + assert "# Package section comment" in text + + +# --------------------------------------------------------------------------- +# update_plugin_entry – happy paths +# --------------------------------------------------------------------------- + + +class TestUpdatePluginHappy: + def test_update_version(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + update_plugin_entry(yml, "existing-package", version=">=2.0.0") + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + entry = data["packages"][0] + assert entry["version"] == ">=2.0.0" + + def test_update_subdir(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + update_plugin_entry( + yml, "existing-package", subdir="src/plugin" + ) + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + entry = data["packages"][0] + assert entry["subdir"] == "src/plugin" + + def test_setting_ref_clears_version(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + update_plugin_entry(yml, "existing-package", ref="deadbeef") + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + entry = data["packages"][0] + assert entry["ref"] == "deadbeef" + assert "version" not in entry + + def test_setting_version_clears_ref(self, tmp_path): + """Start with a ref-pinned entry, then switch to version.""" + content = """\ + name: test-marketplace + description: Test marketplace + version: 1.0.0 + owner: + name: Test Owner + packages: + - name: ref-pkg + source: acme/ref-pkg + ref: abc123 + """ + yml = _write_yml(tmp_path, content) + update_plugin_entry(yml, "ref-pkg", version=">=1.0.0") + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + entry = data["packages"][0] + assert entry["version"] == ">=1.0.0" + assert "ref" not in entry + + def test_unmodified_fields_preserved(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + update_plugin_entry( + yml, "existing-package", subdir="sub/dir" + ) + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + entry = data["packages"][0] + # Original fields are untouched. + assert entry["source"] == "acme/existing-package" + assert entry["version"] == ">=1.0.0" + assert entry["name"] == "existing-package" + + def test_case_insensitive_match(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + update_plugin_entry( + yml, "Existing-Package", subdir="sub/dir" + ) + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + entry = data["packages"][0] + assert entry["subdir"] == "sub/dir" + + +# --------------------------------------------------------------------------- +# update_plugin_entry – error paths +# --------------------------------------------------------------------------- + + +class TestUpdatePluginErrors: + def test_package_not_found_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="not found"): + update_plugin_entry(yml, "nonexistent", version=">=1.0.0") + + def test_both_version_and_ref_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="Cannot specify both"): + update_plugin_entry( + yml, + "existing-package", + version=">=2.0.0", + ref="abc123", + ) + + +# --------------------------------------------------------------------------- +# remove_plugin_entry – happy paths +# --------------------------------------------------------------------------- + + +class TestRemovePluginHappy: + def test_remove_existing_entry(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + remove_plugin_entry(yml, "existing-package") + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + names = [p["name"] for p in (data.get("packages") or [])] + assert "existing-package" not in names + + def test_case_insensitive_removal(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + remove_plugin_entry(yml, "Existing-Package") + data = yaml.safe_load(yml.read_text(encoding="utf-8")) + names = [p["name"] for p in (data.get("packages") or [])] + assert "existing-package" not in names + + +# --------------------------------------------------------------------------- +# remove_plugin_entry – error paths +# --------------------------------------------------------------------------- + + +class TestRemovePluginErrors: + def test_package_not_found_raises(self, tmp_path): + yml = _write_yml(tmp_path, _BASIC_YML) + with pytest.raises(MarketplaceYmlError, match="not found"): + remove_plugin_entry(yml, "nonexistent") diff --git a/tests/unit/marketplace/test_yml_schema.py b/tests/unit/marketplace/test_yml_schema.py new file mode 100644 index 000000000..123ff83b7 --- /dev/null +++ b/tests/unit/marketplace/test_yml_schema.py @@ -0,0 +1,740 @@ +"""Tests for marketplace.yml schema loader and validation.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from apm_cli.marketplace.errors import MarketplaceYmlError +from apm_cli.marketplace.yml_schema import ( + MarketplaceBuild, + MarketplaceOwner, + MarketplaceYml, + PackageEntry, + load_marketplace_yml, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_yml(tmp_path: Path, content: str) -> Path: + """Write *content* to ``marketplace.yml`` inside *tmp_path* and return the path.""" + p = tmp_path / "marketplace.yml" + p.write_text(textwrap.dedent(content), encoding="utf-8") + return p + + +def _minimal_yml(**overrides: object) -> str: + """Return a minimal valid marketplace.yml with optional field overrides. + + Supports overriding top-level scalar fields, ``packages`` (as a raw + YAML fragment), and ``owner`` (as a raw YAML fragment). + """ + fields = { + "name": "acme-tools", + "description": "Acme marketplace", + "version": "1.0.0", + } + fields.update(overrides) + + owner = fields.pop("owner", None) + packages = fields.pop("packages", None) + build = fields.pop("build", None) + metadata = fields.pop("metadata", None) + + lines = [] + for k, v in fields.items(): + lines.append(f"{k}: \"{v}\"" if isinstance(v, str) else f"{k}: {v}") + + if owner is None: + lines.append("owner:") + lines.append(" name: Acme Corp") + else: + lines.append(owner) + + if metadata is not None: + lines.append(metadata) + + if build is not None: + lines.append(build) + + if packages is None: + lines.append("packages:") + lines.append(" - name: tool-a") + lines.append(" source: acme/tool-a") + lines.append(" version: \">=1.0.0\"") + else: + lines.append(packages) + + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +class TestLoadHappyPath: + """Verify that a well-formed marketplace.yml parses correctly.""" + + def test_minimal_valid(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + assert result.name == "acme-tools" + assert result.description == "Acme marketplace" + assert result.version == "1.0.0" + assert result.owner.name == "Acme Corp" + assert result.output == "marketplace.json" + assert result.metadata == {} + assert result.build.tag_pattern == "v{version}" + assert len(result.packages) == 1 + assert result.packages[0].name == "tool-a" + + def test_full_featured(self, tmp_path: Path): + content = """\ + name: acme-tools + description: Full-featured marketplace + version: 2.1.0 + owner: + name: Acme Corp + email: tools@example.com + url: https://example.com + output: dist/marketplace.json + metadata: + pluginRoot: ./plugins + customKey: some-value + build: + tagPattern: "release-{version}" + packages: + - name: linter + source: acme/linter + subdir: packages/core + version: "^1.0.0" + ref: v1.2.3 + tag_pattern: "linter-v{version}" + include_prerelease: true + description: A linting tool + tags: + - lint + - quality + - name: formatter + source: acme/formatter + ref: main + """ + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + + assert result.name == "acme-tools" + assert result.version == "2.1.0" + assert result.owner.email == "tools@example.com" + assert result.owner.url == "https://example.com" + assert result.output == "dist/marketplace.json" + # metadata preserves original casing + assert result.metadata["pluginRoot"] == "./plugins" + assert result.metadata["customKey"] == "some-value" + assert result.build.tag_pattern == "release-{version}" + + linter = result.packages[0] + assert linter.name == "linter" + assert linter.source == "acme/linter" + assert linter.subdir == "packages/core" + assert linter.version == "^1.0.0" + assert linter.ref == "v1.2.3" + assert linter.tag_pattern == "linter-v{version}" + assert linter.include_prerelease is True + assert linter.tags == ("lint", "quality") + + formatter = result.packages[1] + assert formatter.name == "formatter" + assert formatter.ref == "main" + assert formatter.version is None + assert formatter.include_prerelease is False + + def test_metadata_preserved_verbatim(self, tmp_path: Path): + """Anthropic-standard keys in metadata must round-trip with original casing.""" + content = _minimal_yml( + metadata="metadata:\n pluginRoot: ./src\n anotherKey: 42" + ) + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert "pluginRoot" in result.metadata + assert result.metadata["pluginRoot"] == "./src" + assert result.metadata["anotherKey"] == 42 + + def test_empty_packages_list(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml(packages="packages: []")) + result = load_marketplace_yml(yml) + assert result.packages == () + + def test_packages_omitted(self, tmp_path: Path): + """``packages`` key missing defaults to empty tuple.""" + content = """\ + name: acme-tools + description: No packages + version: 0.1.0 + owner: + name: Acme Corp + """ + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.packages == () + + def test_output_default(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + assert result.output == "marketplace.json" + + def test_version_with_prerelease(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml(version="1.0.0-alpha.1")) + result = load_marketplace_yml(yml) + assert result.version == "1.0.0-alpha.1" + + def test_version_with_build_metadata(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml(version="1.0.0+build.42")) + result = load_marketplace_yml(yml) + assert result.version == "1.0.0+build.42" + + def test_tag_pattern_with_name_placeholder(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " version: \">=1.0.0\"\n" + " tag_pattern: \"{name}-v{version}\"" + ) + ) + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.packages[0].tag_pattern == "{name}-v{version}" + + +# --------------------------------------------------------------------------- +# Frozen / immutability +# --------------------------------------------------------------------------- + + +class TestFrozenDataclasses: + """Dataclasses must be immutable.""" + + def test_marketplace_yml_frozen(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + with pytest.raises(AttributeError): + result.name = "nope" + + def test_owner_frozen(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + with pytest.raises(AttributeError): + result.owner.name = "nope" + + def test_package_entry_frozen(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + with pytest.raises(AttributeError): + result.packages[0].name = "nope" + + def test_build_frozen(self): + b = MarketplaceBuild() + with pytest.raises(AttributeError): + b.tag_pattern = "nope" + + +# --------------------------------------------------------------------------- +# Rejection tests -- required fields +# --------------------------------------------------------------------------- + + +class TestRequiredFieldRejection: + """Missing or empty required fields must raise MarketplaceYmlError.""" + + @pytest.mark.parametrize( + "field", + ["name", "description", "version"], + ) + def test_missing_required_scalar(self, tmp_path: Path, field: str): + overrides = { + "name": "acme-tools", + "description": "A marketplace", + "version": "1.0.0", + } + del overrides[field] + # Build YAML without the field + lines = [] + for k, v in overrides.items(): + lines.append(f'{k}: "{v}"') + lines.append("owner:\n name: Acme Corp") + lines.append("packages:\n - name: x\n source: acme/x\n ref: main") + yml = _write_yml(tmp_path, "\n".join(lines) + "\n") + with pytest.raises(MarketplaceYmlError, match=field): + load_marketplace_yml(yml) + + def test_missing_owner(self, tmp_path: Path): + content = """\ + name: acme-tools + description: desc + version: 1.0.0 + packages: + - name: x + source: acme/x + ref: main + """ + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="owner"): + load_marketplace_yml(yml) + + def test_missing_owner_name(self, tmp_path: Path): + content = _minimal_yml(owner="owner:\n email: x@example.com") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="owner.name"): + load_marketplace_yml(yml) + + def test_empty_name(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml(name="")) + with pytest.raises(MarketplaceYmlError, match="name"): + load_marketplace_yml(yml) + + +# --------------------------------------------------------------------------- +# Rejection tests -- version validation +# --------------------------------------------------------------------------- + + +class TestVersionRejection: + """Invalid semver must be rejected.""" + + @pytest.mark.parametrize( + "bad_version", + [ + "not-semver", + "1.0", + "1", + "1.0.0.0", + "abc.def.ghi", + ], + ) + def test_invalid_semver(self, tmp_path: Path, bad_version: str): + yml = _write_yml(tmp_path, _minimal_yml(version=bad_version)) + with pytest.raises(MarketplaceYmlError, match="semver"): + load_marketplace_yml(yml) + + +# --------------------------------------------------------------------------- +# Rejection tests -- packages +# --------------------------------------------------------------------------- + + +class TestPackageEntryRejection: + """Per-entry validation rules.""" + + def test_duplicate_package_names(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " ref: main\n" + " - name: tool-a\n" + " source: acme/tool-a-v2\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="Duplicate"): + load_marketplace_yml(yml) + + def test_duplicate_package_names_case_insensitive(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: Tool-A\n" + " source: acme/tool-a\n" + " ref: main\n" + " - name: tool-a\n" + " source: acme/tool-b\n" + " ref: v1" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="Duplicate"): + load_marketplace_yml(yml) + + def test_missing_package_name(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - source: acme/tool\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="name"): + load_marketplace_yml(yml) + + def test_missing_package_source(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="source"): + load_marketplace_yml(yml) + + def test_invalid_source_shape_no_slash(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: just-a-name\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="source"): + load_marketplace_yml(yml) + + def test_invalid_source_shape_multiple_slashes(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/repo/extra\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="source"): + load_marketplace_yml(yml) + + def test_source_path_traversal(self, tmp_path: Path): + """Source with '..' segments is rejected (regex catches multi-slash first).""" + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: ../evil/repo\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="source"): + load_marketplace_yml(yml) + + def test_source_dotdot_as_owner(self, tmp_path: Path): + """Source with '..' as the owner segment triggers path traversal.""" + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: \"../repo\"\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="traversal"): + load_marketplace_yml(yml) + + def test_subdir_path_traversal(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool\n" + " subdir: ../../etc\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="traversal"): + load_marketplace_yml(yml) + + def test_neither_version_nor_ref(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="version.*ref"): + load_marketplace_yml(yml) + + def test_tag_pattern_no_placeholders(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " ref: main\n" + " tag_pattern: static-tag" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="tag_pattern"): + load_marketplace_yml(yml) + + +# --------------------------------------------------------------------------- +# Rejection tests -- unknown keys (strict mode) +# --------------------------------------------------------------------------- + + +class TestUnknownKeyRejection: + """Unknown keys must be rejected with a clear message.""" + + def test_unknown_top_level_key(self, tmp_path: Path): + content = _minimal_yml() + "unknown_field: value\n" + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="unknown_field"): + load_marketplace_yml(yml) + + def test_unknown_package_entry_key(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " ref: main\n" + " bogus_field: hello" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="bogus_field"): + load_marketplace_yml(yml) + + +# --------------------------------------------------------------------------- +# Rejection tests -- build block +# --------------------------------------------------------------------------- + + +class TestBuildBlockRejection: + """Build-level validation.""" + + def test_build_tag_pattern_no_placeholder(self, tmp_path: Path): + content = _minimal_yml( + build="build:\n tagPattern: static-only" + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="tagPattern"): + load_marketplace_yml(yml) + + def test_build_unknown_key(self, tmp_path: Path): + content = _minimal_yml( + build="build:\n tagPattern: \"v{version}\"\n extraKey: oops" + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="extraKey"): + load_marketplace_yml(yml) + + def test_build_typo_tag_pattern(self, tmp_path: Path): + """Common typo: snake_case ``tag_pattern`` instead of ``tagPattern``.""" + content = _minimal_yml( + build="build:\n tag_pattern: \"v{version}\"" + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="tag_pattern"): + load_marketplace_yml(yml) + + def test_build_permitted_keys_listed(self, tmp_path: Path): + """Error message must list the permitted set so maintainers can self-correct.""" + content = _minimal_yml( + build="build:\n tagPatern: \"v{version}\"" + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="tagPattern"): + load_marketplace_yml(yml) + + +# --------------------------------------------------------------------------- +# Rejection tests -- YAML errors +# --------------------------------------------------------------------------- + + +class TestYamlErrorHandling: + """YAML parse errors are wrapped in MarketplaceYmlError.""" + + def test_invalid_yaml(self, tmp_path: Path): + p = tmp_path / "marketplace.yml" + p.write_text(":\n - :\n bad: [", encoding="utf-8") + with pytest.raises(MarketplaceYmlError, match="YAML parse error"): + load_marketplace_yml(p) + + def test_yaml_not_a_mapping(self, tmp_path: Path): + p = tmp_path / "marketplace.yml" + p.write_text("- a list\n- not a mapping\n", encoding="utf-8") + with pytest.raises(MarketplaceYmlError, match="mapping"): + load_marketplace_yml(p) + + def test_file_not_found(self, tmp_path: Path): + p = tmp_path / "nonexistent.yml" + with pytest.raises(MarketplaceYmlError, match="Cannot read"): + load_marketplace_yml(p) + + +# --------------------------------------------------------------------------- +# Rejection tests -- metadata +# --------------------------------------------------------------------------- + + +class TestMetadataValidation: + """Metadata block edge cases.""" + + def test_metadata_not_a_mapping(self, tmp_path: Path): + content = _minimal_yml(metadata="metadata: not-a-dict") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="metadata"): + load_marketplace_yml(yml) + + def test_metadata_missing_defaults_to_empty(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + assert result.metadata == {} + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Assorted edge-case coverage.""" + + def test_entry_with_only_version_no_ref(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " version: \">=2.0.0\"" + ) + ) + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.packages[0].version == ">=2.0.0" + assert result.packages[0].ref is None + + def test_entry_with_only_ref_no_version(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " ref: v3.0.0" + ) + ) + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.packages[0].ref == "v3.0.0" + assert result.packages[0].version is None + + def test_entry_with_both_version_and_ref(self, tmp_path: Path): + """When both are set, both are stored. The builder resolves precedence.""" + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " version: \">=1.0.0\"\n" + " ref: v1.2.3" + ) + ) + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.packages[0].version == ">=1.0.0" + assert result.packages[0].ref == "v1.2.3" + + def test_include_prerelease_not_bool(self, tmp_path: Path): + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: acme/tool-a\n" + " ref: main\n" + " include_prerelease: yes-please" + ) + ) + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="include_prerelease"): + load_marketplace_yml(yml) + + def test_packages_not_a_list(self, tmp_path: Path): + content = _minimal_yml(packages="packages: not-a-list") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="packages"): + load_marketplace_yml(yml) + + def test_build_not_a_mapping(self, tmp_path: Path): + content = _minimal_yml(build="build: not-a-dict") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="build"): + load_marketplace_yml(yml) + + def test_owner_not_a_mapping(self, tmp_path: Path): + content = _minimal_yml(owner="owner: just-a-string") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="owner"): + load_marketplace_yml(yml) + + def test_source_dot_traversal(self, tmp_path: Path): + """Single-dot segment in source is also traversal.""" + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: ./acme\n" + " ref: main" + ) + ) + yml = _write_yml(tmp_path, content) + # '.' triggers traversal, but also fails the owner/repo regex + with pytest.raises(MarketplaceYmlError): + load_marketplace_yml(yml) + + def test_build_default_tag_pattern(self, tmp_path: Path): + yml = _write_yml(tmp_path, _minimal_yml()) + result = load_marketplace_yml(yml) + assert result.build.tag_pattern == "v{version}" + + +# --------------------------------------------------------------------------- +# S1: Output path traversal guard +# --------------------------------------------------------------------------- + + +class TestOutputPathTraversalGuard: + """The ``output`` field must be rejected if it contains traversal sequences.""" + + def test_output_traversal_rejected(self, tmp_path: Path): + content = _minimal_yml(output="../../etc/passwd") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="traversal"): + load_marketplace_yml(yml) + + def test_output_safe_path_accepted(self, tmp_path: Path): + content = _minimal_yml(output="build/marketplace.json") + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.output == "build/marketplace.json" + + def test_output_dotdot_in_middle_rejected(self, tmp_path: Path): + content = _minimal_yml(output="build/../../../evil.json") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="traversal"): + load_marketplace_yml(yml) + + def test_output_single_dot_rejected(self, tmp_path: Path): + content = _minimal_yml(output="./marketplace.json") + yml = _write_yml(tmp_path, content) + with pytest.raises(MarketplaceYmlError, match="traversal"): + load_marketplace_yml(yml) diff --git a/uv.lock b/uv.lock index e649597cb..f50405677 100644 --- a/uv.lock +++ b/uv.lock @@ -192,6 +192,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "rich-click" }, + { name = "ruamel-yaml" }, { name = "toml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "watchdog" }, @@ -229,6 +230,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.28.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "rich-click", specifier = ">=1.7.0" }, + { name = "ruamel-yaml", specifier = ">=0.18.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.2.0" }, { name = "watchdog", specifier = ">=3.0.0" }, @@ -1595,6 +1597,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/c2/9fce4c8a9587c4e90500114d742fe8ef0fd92d7bad29d136bb9941add271/rich_click-1.8.9-py3-none-any.whl", hash = "sha256:c3fa81ed8a671a10de65a9e20abf642cfdac6fdb882db1ef465ee33919fbcfe2", size = 36082, upload-time = "2025-05-19T21:33:04.195Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "setuptools" version = "80.9.0"