diff --git a/.github/workflows/update-version-dashboard.yml b/.github/workflows/update-version-dashboard.yml index 4e50007c..46662c29 100644 --- a/.github/workflows/update-version-dashboard.yml +++ b/.github/workflows/update-version-dashboard.yml @@ -23,75 +23,8 @@ jobs: - name: Set up Nix uses: cachix/install-nix-action@v31 - - name: Update version dashboard - run: nix-shell --run 'npm run generate:version-compatibility-dashboard' - - - name: Detect changes - id: changes - run: | - if [[ -n "$(git status --porcelain)" ]]; then - echo "has_changes=true" >> "$GITHUB_OUTPUT" - git status --short - git diff --stat - else - echo "has_changes=false" >> "$GITHUB_OUTPUT" - fi - - - name: Check whitespace - if: steps.changes.outputs.has_changes == 'true' - run: git diff --check - - - name: Create or update pull request - if: steps.changes.outputs.has_changes == 'true' + - name: Generate update pull requests env: GH_TOKEN: ${{ github.token }} - PR_BRANCH: version-dashboard/update - PR_TITLE: Update version dashboard data - run: | - set -euo pipefail - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git switch -c "$PR_BRANCH" - git add config/repo-version-config.json docs-main/snippets/generated/version-dashboard-data.mdx - git commit -m "$PR_TITLE" - - if remote_sha="$(git ls-remote --heads origin "$PR_BRANCH" | awk '{print $1}')" && [[ -n "$remote_sha" ]]; then - git push --force-with-lease="refs/heads/$PR_BRANCH:$remote_sha" origin "HEAD:$PR_BRANCH" - else - git push origin "HEAD:$PR_BRANCH" - fi - - pr_body="$(mktemp)" - cat > "$pr_body" <<'EOF' - Updates the committed Canton Network version dashboard data from public network, package, and installer sources. - - Validation run by the workflow: - - `npm run generate:version-compatibility-dashboard` - - `git diff --check` - EOF - - existing_pr_number="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --head "$PR_BRANCH" \ - --base main \ - --state open \ - --json number \ - --jq '.[0].number // empty' - )" - - if [[ -n "$existing_pr_number" ]]; then - gh pr edit "$existing_pr_number" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$PR_TITLE" \ - --body-file "$pr_body" - else - gh pr create \ - --base main \ - --head "$PR_BRANCH" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$PR_TITLE" \ - --body-file "$pr_body" - fi + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/update_generated_reference_prs.py --targets all diff --git a/scripts/docs_env.py b/scripts/docs_env.py index e844cf4c..feeb26fa 100644 --- a/scripts/docs_env.py +++ b/scripts/docs_env.py @@ -9,7 +9,16 @@ DIRENV_ENV_MARKER = "DIGITAL_ASSET_DOCS_DIRENV" +def already_reentered_direnv() -> bool: + return os.environ.get(DIRENV_ENV_MARKER) == "1" + + def repo_direnv_command(repo_root: Path, *args: str) -> list[str]: + if already_reentered_direnv(): + if args and args[0] == "x2mdx": + return ["python3", "-m", "x2mdx.cli", *args[1:]] + return list(args) + if args and args[0] == "x2mdx": return [ "direnv", @@ -25,7 +34,7 @@ def repo_direnv_command(repo_root: Path, *args: str) -> list[str]: def ensure_repo_direnv(*, repo_root: Path, script_path: Path, argv: Sequence[str]) -> None: - if os.environ.get(DIRENV_ENV_MARKER) == "1": + if already_reentered_direnv(): return env = os.environ.copy() diff --git a/scripts/generate_network_component_versions.py b/scripts/generate_network_component_versions.py index 1612cb65..f15c7e15 100644 --- a/scripts/generate_network_component_versions.py +++ b/scripts/generate_network_component_versions.py @@ -5,11 +5,13 @@ import argparse import html import json +import os import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path +from urllib.parse import urlparse from urllib.request import Request, urlopen @@ -119,8 +121,17 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def request_headers(url: str) -> dict[str, str]: + headers = {"User-Agent": USER_AGENT} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token and urlparse(url).netloc == "api.github.com": + headers["Authorization"] = f"Bearer {token}" + headers["X-GitHub-Api-Version"] = "2022-11-28" + return headers + + def request_url(url: str, timeout: float): - request = Request(url, headers={"User-Agent": USER_AGENT}) + request = Request(url, headers=request_headers(url)) return urlopen(request, timeout=timeout) diff --git a/scripts/generate_wallet_gateway_openrpc_reference.py b/scripts/generate_wallet_gateway_openrpc_reference.py index b2f26d6f..4a28b7ef 100644 --- a/scripts/generate_wallet_gateway_openrpc_reference.py +++ b/scripts/generate_wallet_gateway_openrpc_reference.py @@ -237,6 +237,14 @@ def write_details_pages(*, output_dir: Path, spec_entries: list[dict[str, Any]]) ) +def normalize_generated_mdx(output_dir: Path) -> None: + for path in sorted(output_dir.rglob("*.mdx")): + original = path.read_text(encoding="utf-8") + normalized = "\n".join(line.rstrip() for line in original.splitlines()) + "\n" + if normalized != original: + path.write_text(normalized, encoding="utf-8") + + def prune_nav_items(items: list[Any], *, page_refs: set[str], group_labels: set[str]) -> list[Any]: pruned: list[Any] = [] for item in items: @@ -269,14 +277,21 @@ def update_docs_navigation( if not isinstance(navigation, dict): raise ValueError(f"docs.json navigation must be an object: {docs_json_path}") dropdowns = navigation.get("dropdowns") - if not isinstance(dropdowns, list): - raise ValueError(f"docs.json navigation.dropdowns must be a list: {docs_json_path}") - dropdown = next((item for item in dropdowns if isinstance(item, dict) and item.get("dropdown") == dropdown_label), None) - if dropdown is None: - raise ValueError(f"Dropdown not found in docs.json: {dropdown_label}") - pages = dropdown.get("pages") + if isinstance(dropdowns, list): + nav_root = next((item for item in dropdowns if isinstance(item, dict) and item.get("dropdown") == dropdown_label), None) + if nav_root is None: + raise ValueError(f"Dropdown not found in docs.json: {dropdown_label}") + else: + products = navigation.get("products") + if not isinstance(products, list): + raise ValueError(f"docs.json navigation must define dropdowns or products: {docs_json_path}") + nav_root = next((item for item in products if isinstance(item, dict) and item.get("product") == dropdown_label), None) + if nav_root is None: + raise ValueError(f"Product not found in docs.json: {dropdown_label}") + + pages = nav_root.get("pages") if not isinstance(pages, list): - raise ValueError(f"Dropdown does not expose a pages list: {dropdown_label}") + raise ValueError(f"Navigation root does not expose a pages list: {dropdown_label}") refs = {overview_page_ref(output_dir, docs_json_path), docs_json_page_ref(output_dir / "operations" / "details.mdx", docs_json_path)} refs.update(spec_page_ref(output_dir, docs_json_path, spec["spec_id"]) for spec in spec_entries) @@ -310,7 +325,7 @@ def update_docs_navigation( break for offset, wallet_group in enumerate(wallet_groups): pruned_pages.insert(min(insert_at + offset, len(pruned_pages)), wallet_group) - dropdown["pages"] = pruned_pages + nav_root["pages"] = pruned_pages docs_json_path.write_text(json.dumps(docs, indent=2) + "\n", encoding="utf-8") print(f"Updated docs navigation: {docs_json_path}") @@ -453,6 +468,7 @@ def main() -> int: output_dir=Path(args.output_dir).resolve(), spec_entries=spec_entries, ) + normalize_generated_mdx(Path(args.output_dir).resolve()) update_docs_navigation( docs_json_path=Path(args.docs_json).resolve(), dropdown_label=args.nav_dropdown, diff --git a/scripts/generated_reference_pr_utils.py b/scripts/generated_reference_pr_utils.py new file mode 100644 index 00000000..b1c179bc --- /dev/null +++ b/scripts/generated_reference_pr_utils.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path +from typing import Sequence + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def run(command: Sequence[str], *, capture: bool = False) -> str: + kwargs: dict[str, object] = { + "cwd": REPO_ROOT, + "check": True, + "text": True, + } + if capture: + kwargs["stdout"] = subprocess.PIPE + completed = subprocess.run(list(command), **kwargs) + return completed.stdout.strip() if capture else "" + + +def git(*args: str, capture: bool = False) -> str: + return run(("git", *args), capture=capture) + + +def gh(*args: str, capture: bool = False) -> str: + return run(("gh", *args), capture=capture) + + +def current_repository() -> str: + return gh("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner", capture=True) + + +def reset_to_base(*, base_sha: str, clean_paths: Sequence[str]) -> None: + git("switch", "--detach", base_sha) + git("reset", "--hard", base_sha) + git("clean", "-fd", "--", *clean_paths) + + +def write_base_file(base_sha: str, relative_path: str) -> Path: + before = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) + before_path = Path(before.name) + before.write(git("show", f"{base_sha}:{relative_path}", capture=True)) + before.close() + return before_path + + +def has_changes(paths: Sequence[str]) -> bool: + output = git("status", "--porcelain", "--", *paths, capture=True) + return bool(output) + + +def push_branch(branch: str) -> None: + remote_output = git("ls-remote", "--heads", "origin", branch, capture=True) + remote_sha = remote_output.split()[0] if remote_output else "" + if remote_sha: + git( + "push", + f"--force-with-lease=refs/heads/{branch}:{remote_sha}", + "origin", + f"HEAD:{branch}", + ) + else: + git("push", "origin", f"HEAD:{branch}") + + +def create_or_update_pull_request( + *, + title: str, + branch: str, + paths: Sequence[str], + body_path: Path, + base_branch: str, + repository: str, +) -> None: + if not has_changes(paths): + print(f"No changes for {title}") + return + + git("status", "--short", "--", *paths) + git("switch", "-c", branch) + git("add", "--", *paths) + git("diff", "--cached", "--stat") + git("diff", "--cached", "--check") + git("commit", "-m", title) + push_branch(branch) + + existing_pr_number = gh( + "pr", + "list", + "--repo", + repository, + "--head", + branch, + "--base", + base_branch, + "--state", + "open", + "--json", + "number", + "--jq", + ".[0].number // empty", + capture=True, + ) + if existing_pr_number: + gh( + "pr", + "edit", + existing_pr_number, + "--repo", + repository, + "--title", + title, + "--body-file", + str(body_path), + ) + subprocess.run( + [ + "gh", + "pr", + "ready", + existing_pr_number, + "--repo", + repository, + "--undo", + ], + cwd=REPO_ROOT, + check=False, + ) + return + + gh( + "pr", + "create", + "--base", + base_branch, + "--head", + branch, + "--repo", + repository, + "--draft", + "--title", + title, + "--body-file", + str(body_path), + ) diff --git a/scripts/generated_reference_sources/__init__.py b/scripts/generated_reference_sources/__init__.py new file mode 100644 index 00000000..5cbc4ed7 --- /dev/null +++ b/scripts/generated_reference_sources/__init__.py @@ -0,0 +1 @@ +"""Generated-reference source update helpers.""" diff --git a/scripts/generated_reference_sources/common.py b/scripts/generated_reference_sources/common.py new file mode 100644 index 00000000..cbf2823d --- /dev/null +++ b/scripts/generated_reference_sources/common.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class SourceUpdate: + source: str + path: Path + field: str + previous: str + current: str + + +def load_json(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"Expected JSON object in {path}") + return payload + + +def write_json(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") diff --git a/scripts/generated_reference_sources/splice_openapi.py b/scripts/generated_reference_sources/splice_openapi.py new file mode 100644 index 00000000..17899a57 --- /dev/null +++ b/scripts/generated_reference_sources/splice_openapi.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Required, TypedDict + +import generate_splice_mintlify_openapi as splice_openapi_generator + +from generated_reference_sources.common import SourceUpdate, load_json, write_json + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOURCE_KEY = "splice-openapi" +SOURCE_LABEL = "Splice OpenAPI" +DEFAULT_SOURCE_CONFIG = ( + REPO_ROOT / "config" / "mintlify-openapi" / "splice-openapi" / "source-artifacts.json" +) + + +class SpliceOpenApiSpecConfig(TypedDict, total=False): + filename: str + nav_label: str + source: str + directory: str + + +class SpliceOpenApiFamilyConfig(TypedDict, total=False): + group: str + specs: list[SpliceOpenApiSpecConfig] + + +class SpliceOpenApiSourceConfigPayload(TypedDict, total=False): + source: str + release_repo: str + tag_regex: str + min_version: str + publish_version: Required[str] + asset_template: str + nav_dropdown: str + top_level_group_label: str + insert_after_group: str + managed_openapi_root: str + enabled_nav_specs: list[str] + legacy_cleanup_paths: list[str] + families: list[SpliceOpenApiFamilyConfig] + + +@dataclass(frozen=True) +class SpliceOpenApiSourceConfig: + raw: SpliceOpenApiSourceConfigPayload + publish_version: str + + +def parse_source_config(path: Path) -> SpliceOpenApiSourceConfig: + raw_json = load_json(path) + publish_version = raw_json.get("publish_version") + if not isinstance(publish_version, str) or not publish_version: + raise ValueError(f"{path} must define non-empty publish_version") + raw: SpliceOpenApiSourceConfigPayload = {} + raw.update(raw_json) + return SpliceOpenApiSourceConfig(raw=raw, publish_version=publish_version) + + +def latest_version(source_config: SpliceOpenApiSourceConfig) -> str: + releases = splice_openapi_generator.selected_releases( + source_config=source_config.raw, + include_versions=None, + ) + return releases[-1]["version"] + + +def update_source( + *, + source_config_path: Path, + dry_run: bool, +) -> SourceUpdate | None: + source_config = parse_source_config(source_config_path) + current_version = latest_version(source_config) + if source_config.publish_version == current_version: + return None + + update = SourceUpdate( + source=SOURCE_LABEL, + path=source_config_path, + field="publish_version", + previous=source_config.publish_version, + current=current_version, + ) + if not dry_run: + updated_config = dict(source_config.raw) + updated_config["publish_version"] = current_version + write_json(source_config_path, updated_config) + return update diff --git a/scripts/generated_reference_sources/wallet_gateway_openrpc.py b/scripts/generated_reference_sources/wallet_gateway_openrpc.py new file mode 100644 index 00000000..578ede54 --- /dev/null +++ b/scripts/generated_reference_sources/wallet_gateway_openrpc.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Required, TypedDict + +import generate_wallet_gateway_openrpc_reference as wallet_gateway_openrpc_generator + +from generated_reference_sources.common import SourceUpdate, load_json, write_json + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOURCE_KEY = "wallet-gateway-openrpc" +SOURCE_LABEL = "Wallet Gateway OpenRPC" +DEFAULT_SOURCE_CONFIG = ( + REPO_ROOT / "config" / "x2mdx" / "wallet-gateway-openrpc" / "source-artifacts.json" +) + + +class WalletGatewayOpenRpcSpecConfig(TypedDict, total=False): + spec_id: str + display_name: str + source_path: str + + +class WalletGatewayOpenRpcSourceConfigPayload(TypedDict, total=False): + source: str + release_repo: Required[str] + remote: str + tag_prefix: Required[str] + min_version: str + publish_version: Required[str] + specs: list[WalletGatewayOpenRpcSpecConfig] + + +@dataclass(frozen=True) +class WalletGatewayOpenRpcSourceConfig: + raw: WalletGatewayOpenRpcSourceConfigPayload + release_repo: str + tag_prefix: str + min_version: str + publish_version: str + + +def parse_source_config(path: Path) -> WalletGatewayOpenRpcSourceConfig: + raw_json = load_json(path) + release_repo = raw_json.get("release_repo") + tag_prefix = raw_json.get("tag_prefix") + min_version = raw_json.get("min_version") or "0.0.0" + publish_version = raw_json.get("publish_version") + if not isinstance(release_repo, str) or not release_repo: + raise ValueError("Wallet Gateway OpenRPC source config must define release_repo") + if not isinstance(tag_prefix, str) or not tag_prefix: + raise ValueError("Wallet Gateway OpenRPC source config must define tag_prefix") + if not isinstance(min_version, str): + raise ValueError("Wallet Gateway OpenRPC min_version must be a string") + if not isinstance(publish_version, str) or not publish_version: + raise ValueError(f"{path} must define non-empty publish_version") + raw: WalletGatewayOpenRpcSourceConfigPayload = {} + raw.update(raw_json) + return WalletGatewayOpenRpcSourceConfig( + raw=raw, + release_repo=release_repo, + tag_prefix=tag_prefix, + min_version=min_version, + publish_version=publish_version, + ) + + +def latest_version(source_config: WalletGatewayOpenRpcSourceConfig) -> str: + versions = wallet_gateway_openrpc_generator.stable_release_versions( + release_repo=source_config.release_repo, + tag_prefix=source_config.tag_prefix, + min_version=source_config.min_version, + max_version=None, + include_versions=None, + ) + if not versions: + raise ValueError("No Wallet Gateway OpenRPC releases selected") + return versions[-1] + + +def update_source( + *, + source_config_path: Path, + dry_run: bool, +) -> SourceUpdate | None: + source_config = parse_source_config(source_config_path) + current_version = latest_version(source_config) + if source_config.publish_version == current_version: + return None + + update = SourceUpdate( + source=SOURCE_LABEL, + path=source_config_path, + field="publish_version", + previous=source_config.publish_version, + current=current_version, + ) + if not dry_run: + updated_config = dict(source_config.raw) + updated_config["publish_version"] = current_version + write_json(source_config_path, updated_config) + return update diff --git a/scripts/summarize_version_changes.py b/scripts/summarize_version_changes.py new file mode 100644 index 00000000..dece2eb2 --- /dev/null +++ b/scripts/summarize_version_changes.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Iterable, Mapping +from pathlib import Path + + +NETWORK_LABELS = { + "mainnet": "MainNet", + "testnet": "TestNet", + "devnet": "DevNet", +} + +COMPONENT_LABELS = { + "splice": "Splice", + "damlSdk": "Canton / Daml SDK", + "pqs": "PQS", + "tokenStandard": "Token Standard", + "walletSdk": "Wallet SDK", + "dappSdk": "dApp SDK", + "walletGateway": "Wallet Gateway", +} + + +def load_json(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"Expected JSON object in {path}") + return payload + + +def object_mapping(value: object) -> Mapping[str, object] | None: + if isinstance(value, Mapping) and all(isinstance(key, str) for key in value): + return value + return None + + +def object_items(value: object) -> Iterable[Mapping[str, object]]: + if not isinstance(value, list): + return () + return tuple(mapping for item in value if (mapping := object_mapping(item)) is not None) + + +def dar_versions(value: object) -> dict[str, object]: + versions: dict[str, object] = {} + for item in object_items(value): + name = item.get("name") + if isinstance(name, str): + versions[name] = item.get("version") + return versions + + +def format_value(value: object) -> str: + if isinstance(value, str): + return value + return json.dumps(value, sort_keys=True) + + +def repository_version_changes(before: Mapping[str, object], after: Mapping[str, object]) -> list[str]: + changes: list[str] = [] + before_repositories = object_mapping(before.get("repositories")) + after_repositories = object_mapping(after.get("repositories")) + if before_repositories is None or after_repositories is None: + return changes + + for component_key in sorted(after_repositories): + before_component = object_mapping(before_repositories.get(component_key)) + after_component = object_mapping(after_repositories.get(component_key)) + if before_component is None or after_component is None: + continue + before_mapping = object_mapping(before_component.get("versionMapping")) + after_mapping = object_mapping(after_component.get("versionMapping")) + if before_mapping is None or after_mapping is None: + continue + + component_label = COMPONENT_LABELS.get(component_key, component_key) + for network_key in sorted(after_mapping): + before_network = object_mapping(before_mapping.get(network_key)) + after_network = object_mapping(after_mapping.get(network_key)) + if before_network is None or after_network is None: + continue + + before_version = before_network.get("externalVersion") + after_version = after_network.get("externalVersion") + if before_version != after_version: + network_label = NETWORK_LABELS.get(network_key, network_key) + changes.append( + f"- {network_label} {component_label}: " + f"{format_value(before_version)} -> {format_value(after_version)}" + ) + return changes + + +def dar_version_changes(before: Mapping[str, object], after: Mapping[str, object]) -> list[str]: + changes: list[str] = [] + before_versions = object_mapping(before.get("versions")) + after_versions = object_mapping(after.get("versions")) + if before_versions is None or after_versions is None: + return changes + + for network_key in sorted(after_versions): + before_network = object_mapping(before_versions.get(network_key)) + after_network = object_mapping(after_versions.get(network_key)) + if before_network is None or after_network is None: + continue + before_advanced = object_mapping(before_network.get("advanced")) + after_advanced = object_mapping(after_network.get("advanced")) + if before_advanced is None or after_advanced is None: + continue + before_dars = dar_versions(before_advanced.get("darVersions")) + after_dars = dar_versions(after_advanced.get("darVersions")) + for package_name in sorted(after_dars): + if before_dars.get(package_name) != after_dars.get(package_name): + network_label = NETWORK_LABELS.get(network_key, network_key) + changes.append( + f"- {network_label} {package_name} DAR: " + f"{format_value(before_dars.get(package_name))} -> {format_value(after_dars.get(package_name))}" + ) + return changes + + +def dashboard_changes(before_path: Path, after_path: Path) -> list[str]: + before = load_json(before_path) + after = load_json(after_path) + return repository_version_changes(before, after) + dar_version_changes(before, after) + + +def source_config_changes(before_path: Path, after_path: Path, *, label: str) -> list[str]: + before = load_json(before_path) + after = load_json(after_path) + changes: list[str] = [] + for field in ("publish_version", "min_version"): + if before.get(field) != after.get(field): + changes.append( + f"- {label} {field}: {format_value(before.get(field))} -> {format_value(after.get(field))}" + ) + return changes + + +def print_changes(changes: list[str]) -> None: + if changes: + print("\n".join(changes)) + else: + print("- No version values changed.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Summarize before/after version changes as Markdown bullets.") + subparsers = parser.add_subparsers(dest="command", required=True) + + dashboard = subparsers.add_parser("dashboard", help="Summarize repo-version-config.json changes.") + dashboard.add_argument("before", type=Path) + dashboard.add_argument("after", type=Path) + + source_config = subparsers.add_parser("source-config", help="Summarize generated-reference source config changes.") + source_config.add_argument("before", type=Path) + source_config.add_argument("after", type=Path) + source_config.add_argument("--label", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.command == "dashboard": + print_changes(dashboard_changes(args.before, args.after)) + elif args.command == "source-config": + print_changes(source_config_changes(args.before, args.after, label=args.label)) + else: + raise AssertionError(f"Unhandled command: {args.command}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_generated_reference_prs.py b/scripts/update_generated_reference_prs.py new file mode 100644 index 00000000..0b1b6810 --- /dev/null +++ b/scripts/update_generated_reference_prs.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +import generated_reference_pr_utils as pr_utils +import summarize_version_changes + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True) +class UpdateTarget: + key: str + title: str + branch: str + description: str + generate_commands: tuple[tuple[str, ...], ...] + paths: tuple[str, ...] + summary_kind: str + summary_path: str + summary_label: str | None + validation: tuple[str, ...] + + +UPDATE_TARGETS = ( + UpdateTarget( + key="version-dashboard", + title="Update version dashboard data", + branch="version-dashboard/update", + description=( + "Updates the committed Canton Network version dashboard data from public network, " + "package, and installer sources." + ), + generate_commands=(("nix-shell", "--run", "npm run generate:version-compatibility-dashboard"),), + paths=( + "config/repo-version-config.json", + "docs-main/snippets/generated/version-dashboard-data.mdx", + ), + summary_kind="dashboard", + summary_path="config/repo-version-config.json", + summary_label=None, + validation=( + "npm run generate:version-compatibility-dashboard", + "git diff --check", + ), + ), + UpdateTarget( + key="wallet-gateway-openrpc", + title="Update Wallet Gateway OpenRPC reference", + branch="generated-references/wallet-gateway-openrpc/update", + description=( + "Updates the Wallet Gateway OpenRPC source pin to the latest stable " + "wallet-gateway-remote release and regenerates the checked-in Wallet Gateway " + "OpenRPC reference pages." + ), + generate_commands=( + ("nix-shell", "--run", "npm run update:generated-reference-sources -- --source wallet-gateway-openrpc"), + ("nix-shell", "--run", "npm run generate:wallet-gateway-openrpc-reference"), + ), + paths=( + "config/x2mdx/wallet-gateway-openrpc/source-artifacts.json", + "docs-main/docs.json", + "docs-main/reference/wallet-gateway-json-rpc", + ), + summary_kind="source-config", + summary_path="config/x2mdx/wallet-gateway-openrpc/source-artifacts.json", + summary_label="Wallet Gateway OpenRPC", + validation=( + "npm run update:generated-reference-sources -- --source wallet-gateway-openrpc", + "npm run generate:wallet-gateway-openrpc-reference", + "git diff --check", + ), + ), +) + + +def generated_clean_paths() -> tuple[str, ...]: + paths = {".internal"} + for target in UPDATE_TARGETS: + paths.update(target.paths) + return tuple(sorted(paths)) + + +def current_base_branch() -> str: + branch = pr_utils.git("branch", "--show-current", capture=True) + if branch: + return branch + ref_name = os.environ.get("GITHUB_REF_NAME") + if ref_name: + return ref_name + raise RuntimeError("Could not determine base branch; pass --base-branch") + + +def body_markdown(*, target: UpdateTarget, changes: list[str]) -> str: + change_text = "\n".join(changes) if changes else "- No version values changed." + validation = "\n".join(f"- `{command}`" for command in target.validation) + return ( + f"{target.description}\n\n" + f"Version changes:\n" + f"{change_text}\n\n" + f"Validation run by the workflow:\n" + f"{validation}\n" + ) + + +def summarize_target_changes(target: UpdateTarget, before_path: Path) -> list[str]: + after_path = REPO_ROOT / target.summary_path + if target.summary_kind == "dashboard": + return summarize_version_changes.dashboard_changes(before_path, after_path) + if target.summary_kind == "source-config": + if target.summary_label is None: + raise ValueError(f"Update target {target.key} must define summary_label") + return summarize_version_changes.source_config_changes( + before_path, + after_path, + label=target.summary_label, + ) + raise ValueError(f"Unknown summary kind for {target.key}: {target.summary_kind}") + + +def reset_to_base(base_sha: str) -> None: + pr_utils.reset_to_base(base_sha=base_sha, clean_paths=generated_clean_paths()) + + +def create_or_update_pull_request( + *, + target: UpdateTarget, + body_path: Path, + base_branch: str, + repository: str, +) -> None: + pr_utils.create_or_update_pull_request( + title=target.title, + branch=target.branch, + paths=target.paths, + body_path=body_path, + base_branch=base_branch, + repository=repository, + ) + + +def process_target(*, target: UpdateTarget, base_sha: str, base_branch: str, repository: str) -> None: + reset_to_base(base_sha) + before_path = pr_utils.write_base_file(base_sha, target.summary_path) + + for command in target.generate_commands: + pr_utils.run(command) + + changes = summarize_target_changes(target, before_path) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as body_file: + body_path = Path(body_file.name) + body_path.write_text(body_markdown(target=target, changes=changes), encoding="utf-8") + create_or_update_pull_request( + target=target, + body_path=body_path, + base_branch=base_branch, + repository=repository, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate separate update PRs for configured update targets.") + target_keys = tuple(target.key for target in UPDATE_TARGETS) + parser.add_argument( + "--targets", + nargs="+", + required=True, + metavar="TARGET", + choices=("all", *target_keys), + help=f"Targets to run. Use 'all' by itself, or list one or more of: {', '.join(target_keys)}.", + ) + parser.add_argument( + "--base-branch", + help="Base branch for generated PRs. Defaults to the current checkout branch.", + ) + parser.add_argument( + "--repository", + help="GitHub repository for generated PRs. Defaults to the current gh repository.", + ) + args = parser.parse_args() + if "all" in args.targets and len(args.targets) > 1: + parser.error("pass --targets all by itself, or list specific target keys") + args.base_branch = args.base_branch or current_base_branch() + args.repository = args.repository or pr_utils.current_repository() + return args + + +def targets_to_run(target_keys: Sequence[str]) -> tuple[UpdateTarget, ...]: + if not target_keys: + raise ValueError("No update targets selected") + if len(target_keys) == 1 and target_keys[0] == "all": + return UPDATE_TARGETS + if "all" in target_keys: + raise ValueError("'all' cannot be combined with specific update targets") + requested = tuple(dict.fromkeys(target_keys)) + return tuple(target for target in UPDATE_TARGETS if target.key in requested) + + +def main() -> int: + args = parse_args() + pr_utils.git("config", "user.name", "github-actions[bot]") + pr_utils.git("config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") + base_sha = pr_utils.git("rev-parse", "HEAD", capture=True) + + for target in targets_to_run(args.targets): + process_target( + target=target, + base_sha=base_sha, + base_branch=args.base_branch, + repository=args.repository, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_generated_reference_sources.py b/scripts/update_generated_reference_sources.py index 5dc458d0..62ffbec6 100644 --- a/scripts/update_generated_reference_sources.py +++ b/scripts/update_generated_reference_sources.py @@ -3,73 +3,16 @@ from __future__ import annotations import argparse -import json import sys -from dataclasses import dataclass from pathlib import Path -from typing import Any -import generate_splice_mintlify_openapi as splice_openapi +from generated_reference_sources import splice_openapi, wallet_gateway_openrpc +from generated_reference_sources.common import SourceUpdate -REPO_ROOT = Path(__file__).resolve().parents[1] -DEFAULT_SPLICE_OPENAPI_SOURCE_CONFIG = ( - REPO_ROOT / "config" / "mintlify-openapi" / "splice-openapi" / "source-artifacts.json" -) - - -@dataclass(frozen=True) -class SourceUpdate: - source: str - path: Path - field: str - previous: str - current: str - - -def load_json(path: Path) -> dict[str, Any]: - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError(f"Expected JSON object in {path}") - return payload - - -def write_json(path: Path, payload: dict[str, Any]) -> None: - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def latest_splice_openapi_version(source_config: dict[str, Any]) -> str: - releases = splice_openapi.selected_releases( - source_config=source_config, - include_versions=None, - ) - return releases[-1]["version"] - - -def update_splice_openapi_source( - *, - source_config_path: Path, - dry_run: bool, -) -> SourceUpdate | None: - source_config = load_json(source_config_path) - latest_version = latest_splice_openapi_version(source_config) - configured_version = source_config.get("publish_version") - if not isinstance(configured_version, str) or not configured_version: - raise ValueError(f"{source_config_path} must define non-empty publish_version") - if configured_version == latest_version: - return None - - update = SourceUpdate( - source="Splice OpenAPI", - path=source_config_path, - field="publish_version", - previous=configured_version, - current=latest_version, - ) - if not dry_run: - source_config["publish_version"] = latest_version - write_json(source_config_path, source_config) - return update +SOURCE_SPLICE_OPENAPI = splice_openapi.SOURCE_KEY +SOURCE_WALLET_GATEWAY_OPENRPC = wallet_gateway_openrpc.SOURCE_KEY +ALL_SOURCES = (SOURCE_SPLICE_OPENAPI, SOURCE_WALLET_GATEWAY_OPENRPC) def parse_args() -> argparse.Namespace: @@ -79,8 +22,27 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--splice-openapi-source-config", type=Path, - default=DEFAULT_SPLICE_OPENAPI_SOURCE_CONFIG, - help=f"Splice OpenAPI source-artifacts config. Default: {DEFAULT_SPLICE_OPENAPI_SOURCE_CONFIG}", + default=splice_openapi.DEFAULT_SOURCE_CONFIG, + help=f"Splice OpenAPI source-artifacts config. Default: {splice_openapi.DEFAULT_SOURCE_CONFIG}", + ) + parser.add_argument( + "--wallet-gateway-openrpc-source-config", + type=Path, + default=wallet_gateway_openrpc.DEFAULT_SOURCE_CONFIG, + help=( + "Wallet Gateway OpenRPC source-artifacts config. " + f"Default: {wallet_gateway_openrpc.DEFAULT_SOURCE_CONFIG}" + ), + ) + parser.add_argument( + "--source", + action="append", + choices=ALL_SOURCES, + dest="sources", + help=( + "Limit updates to one source. Repeat to update multiple sources. " + "By default, all generated-reference sources are checked." + ), ) parser.add_argument( "--dry-run", @@ -95,18 +57,28 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() +def requested_sources(args: argparse.Namespace) -> tuple[str, ...]: + return tuple(dict.fromkeys(args.sources or ALL_SOURCES)) + + def main() -> int: args = parse_args() - updates = [ - update - for update in [ - update_splice_openapi_source( - source_config_path=args.splice_openapi_source_config.resolve(), - dry_run=args.dry_run or args.check, - ) - ] - if update is not None - ] + sources = requested_sources(args) + updates: list[SourceUpdate] = [] + if SOURCE_SPLICE_OPENAPI in sources: + update = splice_openapi.update_source( + source_config_path=args.splice_openapi_source_config.resolve(), + dry_run=args.dry_run or args.check, + ) + if update is not None: + updates.append(update) + if SOURCE_WALLET_GATEWAY_OPENRPC in sources: + update = wallet_gateway_openrpc.update_source( + source_config_path=args.wallet_gateway_openrpc_source_config.resolve(), + dry_run=args.dry_run or args.check, + ) + if update is not None: + updates.append(update) if not updates: print("Generated reference source pins are up to date.") diff --git a/tests/test_docs_env.py b/tests/test_docs_env.py new file mode 100644 index 00000000..551fd970 --- /dev/null +++ b/tests/test_docs_env.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import importlib +from pathlib import Path + + +def load_docs_env(): + return importlib.import_module("scripts.docs_env") + + +def test_repo_direnv_command_runs_x2mdx_directly_after_direnv_reentry(monkeypatch) -> None: + docs_env = load_docs_env() + monkeypatch.setenv(docs_env.DIRENV_ENV_MARKER, "1") + + assert docs_env.repo_direnv_command(Path("/repo"), "x2mdx", "openrpc") == [ + "python3", + "-m", + "x2mdx.cli", + "openrpc", + ] + + +def test_repo_direnv_command_uses_direnv_even_inside_nix_shell(monkeypatch) -> None: + docs_env = load_docs_env() + monkeypatch.delenv(docs_env.DIRENV_ENV_MARKER, raising=False) + monkeypatch.setenv("IN_NIX_SHELL", "pure") + + assert docs_env.repo_direnv_command(Path("/repo"), "python3", "script.py") == [ + "direnv", + "exec", + "/repo", + "python3", + "script.py", + ] diff --git a/tests/test_generate_network_component_versions.py b/tests/test_generate_network_component_versions.py index b766d59a..ce8916c2 100644 --- a/tests/test_generate_network_component_versions.py +++ b/tests/test_generate_network_component_versions.py @@ -171,6 +171,26 @@ def test_latest_stable_version_ignores_prerelease_and_debug_tags() -> None: ) +def test_request_headers_use_github_token_for_github_api(monkeypatch) -> None: + module = load_script_module() + monkeypatch.setenv("GITHUB_TOKEN", "test-token") + + assert module.request_headers("https://api.github.com/repos/example/project/releases") == { + "User-Agent": module.USER_AGENT, + "Authorization": "Bearer test-token", + "X-GitHub-Api-Version": "2022-11-28", + } + + +def test_request_headers_do_not_send_github_token_to_other_hosts(monkeypatch) -> None: + module = load_script_module() + monkeypatch.setenv("GITHUB_TOKEN", "test-token") + + assert module.request_headers("https://registry.npmjs.org/example") == { + "User-Agent": module.USER_AGENT, + } + + def test_parse_dars_lock_selects_latest_dashboard_packages_only() -> None: module = load_script_module() dars_lock = """ diff --git a/tests/test_summarize_version_changes.py b/tests/test_summarize_version_changes.py new file mode 100644 index 00000000..7920b15e --- /dev/null +++ b/tests/test_summarize_version_changes.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_script_module() -> ModuleType: + script_path = REPO_ROOT / "scripts" / "summarize_version_changes.py" + spec = importlib.util.spec_from_file_location(script_path.stem, script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[script_path.stem] = module + spec.loader.exec_module(module) + return module + + +def write_json(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def dashboard_payload(*, splice_version: str, dar_version: str) -> dict: + return { + "repositories": { + "splice": { + "versionMapping": { + "mainnet": {"externalVersion": splice_version}, + "testnet": {"externalVersion": splice_version}, + } + }, + "walletGateway": { + "versionMapping": { + "mainnet": {"externalVersion": "1.4.0"}, + } + }, + }, + "versions": { + "mainnet": { + "advanced": { + "darVersions": [ + {"name": "splice-wallet", "version": dar_version}, + ] + } + } + }, + } + + +def test_dashboard_changes_summarizes_component_and_dar_versions(tmp_path: Path) -> None: + module = load_script_module() + before = tmp_path / "before.json" + after = tmp_path / "after.json" + write_json(before, dashboard_payload(splice_version="0.6.5", dar_version="0.1.18")) + write_json(after, dashboard_payload(splice_version="0.6.7", dar_version="0.1.19")) + + assert module.dashboard_changes(before, after) == [ + "- MainNet Splice: 0.6.5 -> 0.6.7", + "- TestNet Splice: 0.6.5 -> 0.6.7", + "- MainNet splice-wallet DAR: 0.1.18 -> 0.1.19", + ] + + +def test_dashboard_changes_reports_no_changes(tmp_path: Path) -> None: + module = load_script_module() + before = tmp_path / "before.json" + after = tmp_path / "after.json" + payload = dashboard_payload(splice_version="0.6.5", dar_version="0.1.18") + write_json(before, payload) + write_json(after, payload) + + assert module.dashboard_changes(before, after) == [] + + +def test_source_config_changes_summarizes_publish_version(tmp_path: Path) -> None: + module = load_script_module() + before = tmp_path / "before.json" + after = tmp_path / "after.json" + write_json(before, {"publish_version": "0.25.0", "min_version": "0.24.0"}) + write_json(after, {"publish_version": "1.4.0", "min_version": "0.24.0"}) + + assert module.source_config_changes(before, after, label="Wallet Gateway OpenRPC") == [ + "- Wallet Gateway OpenRPC publish_version: 0.25.0 -> 1.4.0" + ] diff --git a/tests/test_update_generated_reference_prs.py b/tests/test_update_generated_reference_prs.py new file mode 100644 index 00000000..e9c2ec6f --- /dev/null +++ b/tests/test_update_generated_reference_prs.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_script_module() -> ModuleType: + script_path = REPO_ROOT / "scripts" / "update_generated_reference_prs.py" + scripts_dir = str(script_path.parent) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + spec = importlib.util.spec_from_file_location(script_path.stem, script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[script_path.stem] = module + spec.loader.exec_module(module) + return module + + +def test_targets_to_run_accepts_all() -> None: + module = load_script_module() + + assert module.targets_to_run(["all"]) == module.UPDATE_TARGETS + + +def test_targets_to_run_requires_at_least_one_target() -> None: + module = load_script_module() + + try: + module.targets_to_run([]) + except ValueError as error: + assert str(error) == "No update targets selected" + else: + raise AssertionError("Expected targets_to_run to reject an empty target selection") + + +def test_targets_to_run_rejects_mixed_all_and_target_keys() -> None: + module = load_script_module() + + try: + module.targets_to_run(["all", "version-dashboard"]) + except ValueError as error: + assert str(error) == "'all' cannot be combined with specific update targets" + else: + raise AssertionError("Expected targets_to_run to reject mixed all and target keys") + + +def test_targets_to_run_preserves_declared_target_order_for_target_keys() -> None: + module = load_script_module() + + targets = module.targets_to_run(["wallet-gateway-openrpc", "version-dashboard"]) + + assert [target.key for target in targets] == ["version-dashboard", "wallet-gateway-openrpc"] + + +def test_generated_clean_paths_include_target_paths_and_internal_output() -> None: + module = load_script_module() + + clean_paths = module.generated_clean_paths() + + assert ".internal" in clean_paths + assert "docs-main/reference/wallet-gateway-json-rpc" in clean_paths + assert "docs-main/snippets/generated/version-dashboard-data.mdx" in clean_paths + + +def test_body_markdown_includes_description_changes_and_validation() -> None: + module = load_script_module() + target = next(target for target in module.UPDATE_TARGETS if target.key == "wallet-gateway-openrpc") + + body = module.body_markdown( + target=target, + changes=["- Wallet Gateway OpenRPC publish_version: 0.25.0 -> 1.4.0"], + ) + + assert body.startswith("Updates the Wallet Gateway OpenRPC source pin") + assert "Version changes:\n- Wallet Gateway OpenRPC publish_version: 0.25.0 -> 1.4.0" in body + assert "- `npm run generate:wallet-gateway-openrpc-reference`" in body + + +def test_body_markdown_notes_when_no_versions_changed() -> None: + module = load_script_module() + target = next(target for target in module.UPDATE_TARGETS if target.key == "version-dashboard") + + body = module.body_markdown(target=target, changes=[]) + + assert "Version changes:\n- No version values changed." in body + + +def test_parse_args_defaults_base_branch_and_repository_from_local_context(monkeypatch) -> None: + module = load_script_module() + monkeypatch.setattr( + sys, + "argv", + [ + "update_generated_reference_prs.py", + "--targets", + "all", + ], + ) + monkeypatch.setattr( + module.pr_utils, + "git", + lambda *args, capture=False: "wallet-gateway-openrpc-refresh" + if args == ("branch", "--show-current") and capture + else "", + ) + monkeypatch.setattr(module.pr_utils, "current_repository", lambda: "canton-network/cf-docs") + + args = module.parse_args() + + assert args.base_branch == "wallet-gateway-openrpc-refresh" + assert args.repository == "canton-network/cf-docs" + + +def test_parse_args_accepts_explicit_base_branch_and_repository(monkeypatch) -> None: + module = load_script_module() + monkeypatch.setattr( + sys, + "argv", + [ + "update_generated_reference_prs.py", + "--targets", + "all", + "--base-branch", + "main", + "--repository", + "canton-network/cf-docs", + ], + ) + + args = module.parse_args() + + assert args.base_branch == "main" + assert args.repository == "canton-network/cf-docs" + + +def test_current_base_branch_uses_github_ref_name_for_detached_checkout(monkeypatch) -> None: + module = load_script_module() + monkeypatch.setattr( + module.pr_utils, + "git", + lambda *args, capture=False: "" if args == ("branch", "--show-current") and capture else "", + ) + monkeypatch.setenv("GITHUB_REF_NAME", "main") + + assert module.current_base_branch() == "main" diff --git a/tests/test_update_generated_reference_sources.py b/tests/test_update_generated_reference_sources.py index 07e5d03e..87b65536 100644 --- a/tests/test_update_generated_reference_sources.py +++ b/tests/test_update_generated_reference_sources.py @@ -42,16 +42,35 @@ def write_source_config(path: Path, *, publish_version: str) -> None: ) +def write_wallet_gateway_source_config(path: Path, *, publish_version: str) -> None: + path.write_text( + json.dumps( + { + "source": "test", + "release_repo": "hyperledger-labs/splice-wallet-kernel", + "remote": "https://github.com/hyperledger-labs/splice-wallet-kernel.git", + "tag_prefix": "@canton-network/wallet-gateway-remote@", + "min_version": "0.24.0", + "publish_version": publish_version, + "specs": [], + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + def test_update_splice_openapi_source_updates_stale_publish_version(tmp_path: Path) -> None: module = load_script_module() source_config_path = tmp_path / "source-artifacts.json" write_source_config(source_config_path, publish_version="0.5.18") - module.splice_openapi.selected_releases = lambda **_kwargs: [ + module.splice_openapi.splice_openapi_generator.selected_releases = lambda **_kwargs: [ {"version": "0.5.18"}, {"version": "0.6.7"}, ] - update = module.update_splice_openapi_source( + update = module.splice_openapi.update_source( source_config_path=source_config_path, dry_run=False, ) @@ -70,13 +89,13 @@ def test_update_splice_openapi_source_noops_when_current(tmp_path: Path) -> None module = load_script_module() source_config_path = tmp_path / "source-artifacts.json" write_source_config(source_config_path, publish_version="0.6.7") - module.splice_openapi.selected_releases = lambda **_kwargs: [ + module.splice_openapi.splice_openapi_generator.selected_releases = lambda **_kwargs: [ {"version": "0.5.18"}, {"version": "0.6.7"}, ] assert ( - module.update_splice_openapi_source( + module.splice_openapi.update_source( source_config_path=source_config_path, dry_run=False, ) @@ -89,12 +108,12 @@ def test_update_splice_openapi_source_dry_run_does_not_write(tmp_path: Path) -> module = load_script_module() source_config_path = tmp_path / "source-artifacts.json" write_source_config(source_config_path, publish_version="0.5.18") - module.splice_openapi.selected_releases = lambda **_kwargs: [ + module.splice_openapi.splice_openapi_generator.selected_releases = lambda **_kwargs: [ {"version": "0.5.18"}, {"version": "0.6.7"}, ] - update = module.update_splice_openapi_source( + update = module.splice_openapi.update_source( source_config_path=source_config_path, dry_run=True, ) @@ -103,3 +122,96 @@ def test_update_splice_openapi_source_dry_run_does_not_write(tmp_path: Path) -> assert update.previous == "0.5.18" assert update.current == "0.6.7" assert json.loads(source_config_path.read_text(encoding="utf-8"))["publish_version"] == "0.5.18" + + +def test_update_wallet_gateway_openrpc_source_updates_stale_publish_version(tmp_path: Path) -> None: + module = load_script_module() + source_config_path = tmp_path / "source-artifacts.json" + write_wallet_gateway_source_config(source_config_path, publish_version="0.25.0") + module.wallet_gateway_openrpc.wallet_gateway_openrpc_generator.stable_release_versions = ( + lambda **_kwargs: [ + "0.25.0", + "1.4.0", + ] + ) + + update = module.wallet_gateway_openrpc.update_source( + source_config_path=source_config_path, + dry_run=False, + ) + + assert update == module.SourceUpdate( + source="Wallet Gateway OpenRPC", + path=source_config_path, + field="publish_version", + previous="0.25.0", + current="1.4.0", + ) + assert json.loads(source_config_path.read_text(encoding="utf-8"))["publish_version"] == "1.4.0" + + +def test_update_wallet_gateway_openrpc_source_noops_when_current(tmp_path: Path) -> None: + module = load_script_module() + source_config_path = tmp_path / "source-artifacts.json" + write_wallet_gateway_source_config(source_config_path, publish_version="1.4.0") + module.wallet_gateway_openrpc.wallet_gateway_openrpc_generator.stable_release_versions = ( + lambda **_kwargs: [ + "0.25.0", + "1.4.0", + ] + ) + + assert ( + module.wallet_gateway_openrpc.update_source( + source_config_path=source_config_path, + dry_run=False, + ) + is None + ) + assert json.loads(source_config_path.read_text(encoding="utf-8"))["publish_version"] == "1.4.0" + + +def test_update_wallet_gateway_openrpc_source_dry_run_does_not_write(tmp_path: Path) -> None: + module = load_script_module() + source_config_path = tmp_path / "source-artifacts.json" + write_wallet_gateway_source_config(source_config_path, publish_version="0.25.0") + module.wallet_gateway_openrpc.wallet_gateway_openrpc_generator.stable_release_versions = ( + lambda **_kwargs: [ + "0.25.0", + "1.4.0", + ] + ) + + update = module.wallet_gateway_openrpc.update_source( + source_config_path=source_config_path, + dry_run=True, + ) + + assert update is not None + assert update.previous == "0.25.0" + assert update.current == "1.4.0" + assert json.loads(source_config_path.read_text(encoding="utf-8"))["publish_version"] == "0.25.0" + + +def test_requested_sources_defaults_to_all_sources() -> None: + module = load_script_module() + + assert module.requested_sources(type("Args", (), {"sources": None})()) == module.ALL_SOURCES + + +def test_requested_sources_preserves_order_and_deduplicates() -> None: + module = load_script_module() + + assert module.requested_sources( + type( + "Args", + (), + { + "sources": [ + "wallet-gateway-openrpc", + "splice-openapi", + "wallet-gateway-openrpc", + ] + }, + )() + ) == ("wallet-gateway-openrpc", "splice-openapi") diff --git a/tests/test_wallet_kernel_nav.py b/tests/test_wallet_kernel_nav.py index 75380731..2d09344f 100644 --- a/tests/test_wallet_kernel_nav.py +++ b/tests/test_wallet_kernel_nav.py @@ -128,6 +128,65 @@ def test_openrpc_nav_uses_wallet_gateway_section_shape(tmp_path: Path) -> None: ] +def test_openrpc_nav_updates_product_navigation(tmp_path: Path) -> None: + generate_wallet_gateway_openrpc_reference = load_script("generate_wallet_gateway_openrpc_reference") + docs_json = tmp_path / "docs-main" / "docs.json" + docs_json.parent.mkdir(parents=True) + docs_json.write_text( + json.dumps( + { + "navigation": { + "products": [ + { + "product": "API Reference", + "pages": [ + {"group": "TypeScript", "pages": []}, + {"group": "Wallet Kernel SDK", "pages": ["old-wallet"]}, + {"group": "Splice APIs", "pages": []}, + ], + } + ] + } + } + ), + encoding="utf-8", + ) + output_dir = docs_json.parent / "reference" / "wallet-gateway-json-rpc" + + write_mdx(output_dir / "index.mdx", "Wallet Gateway") + write_mdx(output_dir / "specs" / "user-api.mdx", "User API") + write_mdx(output_dir / "operations" / "user-api" / "createWallet.mdx", "createWallet") + write_mdx(output_dir / "operations" / "user-api" / "details.mdx", "Details and history") + write_mdx(output_dir / "operations" / "details.mdx", "Details and history") + + generate_wallet_gateway_openrpc_reference.update_docs_navigation( + docs_json_path=docs_json, + dropdown_label="API Reference", + output_dir=output_dir, + spec_entries=[{"spec_id": "user-api"}], + ) + + docs = json.loads(docs_json.read_text(encoding="utf-8")) + pages = docs["navigation"]["products"][0]["pages"] + assert pages == [ + {"group": "TypeScript", "pages": []}, + { + "group": "Wallet Gateway", + "pages": [ + { + "group": "User API", + "pages": [ + "reference/wallet-gateway-json-rpc/operations/user-api/createWallet", + "reference/wallet-gateway-json-rpc/operations/user-api/details", + ], + }, + "reference/wallet-gateway-json-rpc/operations/details", + ], + }, + {"group": "Splice APIs", "pages": []}, + ] + + def test_openrpc_nav_group_helper_omits_redundant_spec_page_child(tmp_path: Path) -> None: generated_reference_nav = load_script("generated_reference_nav") docs_json = tmp_path / "docs-main" / "docs.json"