From 8fab74c81fa6e44dbccad8856b2740be01b49801 Mon Sep 17 00:00:00 2001 From: danielporterda Date: Mon, 15 Jun 2026 12:04:49 -0400 Subject: [PATCH] Add generated reference update PR orchestration Add the version-change summary utility, move the version-dashboard workflow PR creation into a Python orchestrator, and add the Wallet Gateway OpenRPC generated-reference PR target. Signed-off-by: danielporterda --- .../workflows/update-version-dashboard.yml | 81 +------ scripts/generated_reference_pr_utils.py | 148 ++++++++++++ scripts/summarize_version_changes.py | 178 ++++++++++++++ scripts/update_generated_reference_prs.py | 225 ++++++++++++++++++ tests/test_summarize_version_changes.py | 89 +++++++ tests/test_update_generated_reference_prs.py | 152 ++++++++++++ 6 files changed, 795 insertions(+), 78 deletions(-) create mode 100644 scripts/generated_reference_pr_utils.py create mode 100644 scripts/summarize_version_changes.py create mode 100644 scripts/update_generated_reference_prs.py create mode 100644 tests/test_summarize_version_changes.py create mode 100644 tests/test_update_generated_reference_prs.py diff --git a/.github/workflows/update-version-dashboard.yml b/.github/workflows/update-version-dashboard.yml index 62baec45..46662c29 100644 --- a/.github/workflows/update-version-dashboard.yml +++ b/.github/workflows/update-version-dashboard.yml @@ -23,83 +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 - PR_REVIEWERS: Jatinp26,shreyas-da,hrischuk-da - 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 - pr_number="$existing_pr_number" - gh pr edit "$existing_pr_number" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$PR_TITLE" \ - --body-file "$pr_body" - else - pr_url="$(gh pr create \ - --base main \ - --head "$PR_BRANCH" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$PR_TITLE" \ - --body-file "$pr_body")" - pr_number="${pr_url##*/}" - echo "$pr_url" - fi - - gh pr edit "$pr_number" \ - --repo "$GITHUB_REPOSITORY" \ - --add-reviewer "$PR_REVIEWERS" + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/update_generated_reference_prs.py --targets all 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/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/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"