From d50cd9977ac478deca249cad0dc0096b5ae0e95e Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 15:24:31 +0100 Subject: [PATCH 1/7] add boilerplate for auto-updating --- .noble_version_tracker.json | 4 + README.md | 15 ++- docs/build/endpoints/mainnet.mdx | 1 + requirements.txt | 2 + scripts/check_noble_version.py | 203 +++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 .noble_version_tracker.json create mode 100644 requirements.txt create mode 100755 scripts/check_noble_version.py diff --git a/.noble_version_tracker.json b/.noble_version_tracker.json new file mode 100644 index 0000000..b4a4c98 --- /dev/null +++ b/.noble_version_tracker.json @@ -0,0 +1,4 @@ +{ + "last_tracked_version": "v11.0.0", + "last_checked": "2025-11-14T14:17:22.730232+00:00" +} \ No newline at end of file diff --git a/README.md b/README.md index f969033..a33b98b 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ ### Installation ```sh -$ yarn +$ bun ``` ### Local Development ```sh -$ yarn start +$ bun start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. @@ -19,7 +19,16 @@ This command starts a local development server and opens up a browser window. Mo ### Build ```sh -$ yarn build +$ bun build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. + +### Updating The Module References + +This repository is set up to track the concrete used Noble (and its modules) version and make any required changes to the existing documentation +based on new changes in the repositories. +For this purpose, there is a Python script, that retrieves the latest Mainnet upgrade version from the contained list of upgrades +and checks for the last updated version of the docs. +If there is a mismatch, the diff between the Noble repository tags is retrieved and prompted to an LLM to generate a list of required changes +to this repository. diff --git a/docs/build/endpoints/mainnet.mdx b/docs/build/endpoints/mainnet.mdx index 179074c..1be1d05 100644 --- a/docs/build/endpoints/mainnet.mdx +++ b/docs/build/endpoints/mainnet.mdx @@ -4,6 +4,7 @@ description: "" sidebar_position: 1 --- + ### Chain ID: `noble-1` ### RPC diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..11d8fe8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 + diff --git a/scripts/check_noble_version.py b/scripts/check_noble_version.py new file mode 100755 index 0000000..af1cf14 --- /dev/null +++ b/scripts/check_noble_version.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Noble Documentation Version Tracker + +This script checks the latest Noble version from the chain upgrades documentation +and compares it with the last tracked version. If there's a mismatch, it can +generate a diff and suggest documentation updates. +""" + +import json +import re +import sys +from pathlib import Path +from typing import Optional, Tuple +from datetime import datetime, timezone + + +# Configuration +REPO_ROOT = Path(__file__).parent.parent +MAINNET_MDX_PATH = REPO_ROOT / "docs" / "build" / "chain-upgrades" / "mainnet.mdx" +TRACKER_JSON_PATH = REPO_ROOT / ".noble_version_tracker.json" +GITHUB_REPO = "noble-assets/noble" + + +def parse_version(version_str: str) -> Tuple[int, int, int]: + """ + Parse a version string (e.g., 'v11.0.0') into a tuple of (major, minor, patch). + + Args: + version_str: Version string in format 'vX.Y.Z' or 'X.Y.Z' + + Returns: + Tuple of (major, minor, patch) integers + """ + # Remove 'v' prefix if present + version_str = version_str.lstrip('v') + parts = version_str.split('.') + if len(parts) != 3: + raise ValueError(f"Invalid version format: {version_str}") + return (int(parts[0]), int(parts[1]), int(parts[2])) + + +def compare_versions(version1: str, version2: str) -> int: + """ + Compare two version strings. + + Returns: + -1 if version1 < version2 + 0 if version1 == version2 + 1 if version1 > version2 + """ + v1 = parse_version(version1) + v2 = parse_version(version2) + + if v1 < v2: + return -1 + elif v1 > v2: + return 1 + else: + return 0 + + +def get_latest_version_from_upgrades(mdx_path: Path) -> Optional[str]: + """ + Parse the mainnet.mdx file and extract the latest version from the upgrades table. + + Args: + mdx_path: Path to the mainnet.mdx file + + Returns: + Latest version string (e.g., 'v11.0.0') or None if not found + """ + try: + with open(mdx_path, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + print(f"Error: File not found: {mdx_path}", file=sys.stderr) + return None + except Exception as e: + print(f"Error reading file {mdx_path}: {e}", file=sys.stderr) + return None + + # Pattern to match version tags in markdown links: [`vX.Y.Z`](url) + # This matches patterns like [`v11.0.0`](https://github.com/...) + version_pattern = r'\[`(v\d+\.\d+\.\d+)`\]' + + # Find all version matches + versions = re.findall(version_pattern, content) + + if not versions: + print("Warning: No versions found in the upgrades table", file=sys.stderr) + return None + + # Find the latest version by comparing all found versions + latest_version = versions[0] + for version in versions[1:]: + if compare_versions(version, latest_version) > 0: + latest_version = version + + return latest_version + + +def load_tracker() -> dict: + """ + Load the version tracker JSON file. + + Returns: + Dictionary with tracker data, or default structure if file doesn't exist + """ + if not TRACKER_JSON_PATH.exists(): + return { + "last_tracked_version": None, + "last_checked": None + } + + try: + with open(TRACKER_JSON_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in tracker file: {e}", file=sys.stderr) + return { + "last_tracked_version": None, + "last_checked": None + } + except Exception as e: + print(f"Error reading tracker file: {e}", file=sys.stderr) + return { + "last_tracked_version": None, + "last_checked": None + } + + +def save_tracker(version: str): + """ + Save the current version to the tracker JSON file. + + Args: + version: Version string to save + """ + tracker_data = { + "last_tracked_version": version, + "last_checked": datetime.now(timezone.utc).isoformat() + } + + try: + with open(TRACKER_JSON_PATH, 'w', encoding='utf-8') as f: + json.dump(tracker_data, f, indent=2) + except Exception as e: + print(f"Error writing tracker file: {e}", file=sys.stderr) + + +def main(): + """Main function to check version and compare with tracker.""" + print("Noble Documentation Version Tracker") + print("=" * 50) + + # Get latest version from upgrades table + print(f"\nReading upgrades from: {MAINNET_MDX_PATH}") + latest_version = get_latest_version_from_upgrades(MAINNET_MDX_PATH) + + if not latest_version: + print("Error: Could not determine latest version from upgrades table", file=sys.stderr) + sys.exit(1) + + print(f"Latest version in upgrades table: {latest_version}") + + # Load tracker + tracker = load_tracker() + last_tracked = tracker.get("last_tracked_version") + + if last_tracked: + print(f"Last tracked version: {last_tracked}") + + # Compare versions + comparison = compare_versions(last_tracked, latest_version) + + if comparison == 0: + print("\n✓ Versions match! Documentation is up to date.") + # Update last_checked timestamp + save_tracker(latest_version) + sys.exit(0) + elif comparison < 0: + print(f"\n⚠ Version mismatch detected!") + print(f" Last tracked: {last_tracked}") + print(f" Latest in docs: {latest_version}") + print(f"\n The documentation has been updated with a new version.") + print(f" Next step: Generate diff between {last_tracked} and {latest_version}") + # TODO: Generate diff and LLM suggestions + else: + print(f"\n⚠ Warning: Last tracked version ({last_tracked}) is newer than latest in docs ({latest_version})") + print(" This shouldn't happen - the docs may have been reverted.") + else: + print("\nNo previous version tracked (first run)") + print(f"Setting initial tracked version to: {latest_version}") + save_tracker(latest_version) + print("\n✓ Tracker initialized. Run again to check for updates.") + + print("\n" + "=" * 50) + + +if __name__ == "__main__": + main() + From ceb9b303a4494e056caaca6bb033a2a6098a4a9a Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 15:27:58 +0100 Subject: [PATCH 2/7] better handling for unsupported version suffixes --- .gitignore | 2 + .noble_version_tracker.json | 2 +- scripts/check_noble_version.py | 78 ++++++++++++++++++++++++++++------ 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index b2d6de3..836830f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +**/__pycache__ \ No newline at end of file diff --git a/.noble_version_tracker.json b/.noble_version_tracker.json index b4a4c98..4928fd9 100644 --- a/.noble_version_tracker.json +++ b/.noble_version_tracker.json @@ -1,4 +1,4 @@ { "last_tracked_version": "v11.0.0", - "last_checked": "2025-11-14T14:17:22.730232+00:00" + "last_checked": "2025-11-14T14:26:11.920165+00:00" } \ No newline at end of file diff --git a/scripts/check_noble_version.py b/scripts/check_noble_version.py index af1cf14..ed283a8 100755 --- a/scripts/check_noble_version.py +++ b/scripts/check_noble_version.py @@ -31,13 +31,34 @@ def parse_version(version_str: str) -> Tuple[int, int, int]: Returns: Tuple of (major, minor, patch) integers + + Raises: + ValueError: If version format is invalid or contains suffixes (e.g., -rc1) """ + # Check for version suffixes (e.g., -rc1, -beta, -alpha) + if '-' in version_str: + raise ValueError( + f"Version with suffix detected: '{version_str}'\n" + f"This tool currently does not support version suffixes (e.g., -rc1, -beta, -alpha).\n" + f"Please use only release versions in the format 'vX.Y.Z' (e.g., 'v11.0.0')." + ) + # Remove 'v' prefix if present version_str = version_str.lstrip('v') parts = version_str.split('.') if len(parts) != 3: - raise ValueError(f"Invalid version format: {version_str}") - return (int(parts[0]), int(parts[1]), int(parts[2])) + raise ValueError( + f"Invalid version format: '{version_str}'\n" + f"Expected format: 'vX.Y.Z' or 'X.Y.Z' where X, Y, Z are integers." + ) + + try: + return (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError as e: + raise ValueError( + f"Invalid version format: '{version_str}'\n" + f"All version components must be integers. Error: {e}" + ) def compare_versions(version1: str, version2: str) -> int: @@ -48,9 +69,16 @@ def compare_versions(version1: str, version2: str) -> int: -1 if version1 < version2 0 if version1 == version2 1 if version1 > version2 + + Raises: + ValueError: If either version format is invalid or contains suffixes """ - v1 = parse_version(version1) - v2 = parse_version(version2) + try: + v1 = parse_version(version1) + v2 = parse_version(version2) + except ValueError as e: + # Re-raise with context about which version failed + raise ValueError(f"Error comparing versions '{version1}' and '{version2}':\n{e}") if v1 < v2: return -1 @@ -80,9 +108,10 @@ def get_latest_version_from_upgrades(mdx_path: Path) -> Optional[str]: print(f"Error reading file {mdx_path}: {e}", file=sys.stderr) return None - # Pattern to match version tags in markdown links: [`vX.Y.Z`](url) - # This matches patterns like [`v11.0.0`](https://github.com/...) - version_pattern = r'\[`(v\d+\.\d+\.\d+)`\]' + # Pattern to match version tags in markdown links: [`vX.Y.Z`](url) or [`vX.Y.Z-suffix`](url) + # This matches patterns like [`v11.0.0`](https://github.com/...) or [`v11.0.0-rc1`](...) + # We capture versions with optional suffixes to detect and handle them + version_pattern = r'\[`(v\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+)?)`\]' # Find all version matches versions = re.findall(version_pattern, content) @@ -91,11 +120,31 @@ def get_latest_version_from_upgrades(mdx_path: Path) -> Optional[str]: print("Warning: No versions found in the upgrades table", file=sys.stderr) return None + # Check for versions with suffixes and report them + versions_with_suffixes = [v for v in versions if '-' in v] + if versions_with_suffixes: + print("\n⚠ Warning: Found versions with suffixes in the upgrades table:", file=sys.stderr) + for v in versions_with_suffixes: + print(f" - {v}", file=sys.stderr) + print("\nThis tool cannot handle version suffixes (e.g., -rc1, -beta, -alpha).", file=sys.stderr) + print("Please update the documentation to use only release versions.", file=sys.stderr) + print("\nError details:", file=sys.stderr) + try: + # Try to parse one to trigger the error message + parse_version(versions_with_suffixes[0]) + except ValueError as e: + print(f"{e}", file=sys.stderr) + return None + # Find the latest version by comparing all found versions - latest_version = versions[0] - for version in versions[1:]: - if compare_versions(version, latest_version) > 0: - latest_version = version + try: + latest_version = versions[0] + for version in versions[1:]: + if compare_versions(version, latest_version) > 0: + latest_version = version + except ValueError as e: + print(f"\nError: Failed to compare versions: {e}", file=sys.stderr) + return None return latest_version @@ -172,7 +221,12 @@ def main(): print(f"Last tracked version: {last_tracked}") # Compare versions - comparison = compare_versions(last_tracked, latest_version) + try: + comparison = compare_versions(last_tracked, latest_version) + except ValueError as e: + print(f"\n❌ Error: {e}", file=sys.stderr) + print("\nPlease ensure both versions are in the format 'vX.Y.Z' without suffixes.", file=sys.stderr) + sys.exit(1) if comparison == 0: print("\n✓ Versions match! Documentation is up to date.") From 59faa4f07e6e4423b65075cc03ee3690340751b6 Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 15:37:34 +0100 Subject: [PATCH 3/7] use uv for python venv management --- .gitignore | 4 +++- .mise.toml | 30 ++++++++++++++++++++++++++++++ .noble_version_tracker.json | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .mise.toml diff --git a/.gitignore b/.gitignore index 836830f..73cd8eb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -**/__pycache__ \ No newline at end of file +# Python +**/__pycache__ +.venv \ No newline at end of file diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..7ed7dd6 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,30 @@ +[tools] +# Node.js - required for Docusaurus (package.json specifies >=18.0) +node = "20.18.0" +# Python - required for version tracking script +python = "3.12" +# Bun - used for package management and running scripts +bun = "1.2.13" +# uv - fast Python package installer and resolver +uv = "latest" + +[tasks.install] +description = "Install all dependencies" +run = [ + "bun install", + "uv venv", + "uv pip install --python .venv/bin/python -r requirements.txt" +] + +[tasks.start] +description = "Start local development server" +run = "bun start" + +[tasks.build] +description = "Build static content" +run = "bun build" + +[tasks.check-version] +description = "Check Noble version and compare with tracker" +run = "uv run python scripts/check_noble_version.py" + diff --git a/.noble_version_tracker.json b/.noble_version_tracker.json index 4928fd9..f6774bc 100644 --- a/.noble_version_tracker.json +++ b/.noble_version_tracker.json @@ -1,4 +1,4 @@ { "last_tracked_version": "v11.0.0", - "last_checked": "2025-11-14T14:26:11.920165+00:00" + "last_checked": "2025-11-14T14:36:58.293690+00:00" } \ No newline at end of file From e897329e6fa999888dc31d63fe9d8a56500e4659 Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 15:49:36 +0100 Subject: [PATCH 4/7] add some unit tests --- .mise.toml | 6 +- requirements.txt | 1 + scripts/check_noble_version.py | 20 ++- tests/__init__.py | 2 + tests/test_check_noble_version.py | 245 ++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_check_noble_version.py diff --git a/.mise.toml b/.mise.toml index 7ed7dd6..ac878c4 100644 --- a/.mise.toml +++ b/.mise.toml @@ -12,7 +12,7 @@ uv = "latest" description = "Install all dependencies" run = [ "bun install", - "uv venv", + "uv venv --clear", "uv pip install --python .venv/bin/python -r requirements.txt" ] @@ -28,3 +28,7 @@ run = "bun build" description = "Check Noble version and compare with tracker" run = "uv run python scripts/check_noble_version.py" +[tasks.test] +description = "Run pytest unit tests" +run = "uv run pytest tests/ -v" + diff --git a/requirements.txt b/requirements.txt index 11d8fe8..6ed9cd9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests>=2.31.0 +pytest>=8.0.0 diff --git a/scripts/check_noble_version.py b/scripts/check_noble_version.py index ed283a8..724bb6e 100755 --- a/scripts/check_noble_version.py +++ b/scripts/check_noble_version.py @@ -149,21 +149,27 @@ def get_latest_version_from_upgrades(mdx_path: Path) -> Optional[str]: return latest_version -def load_tracker() -> dict: +def load_tracker(tracker_path: Optional[Path] = None) -> dict: """ Load the version tracker JSON file. + Args: + tracker_path: Optional path to tracker file. Defaults to TRACKER_JSON_PATH. + Returns: Dictionary with tracker data, or default structure if file doesn't exist """ - if not TRACKER_JSON_PATH.exists(): + if tracker_path is None: + tracker_path = TRACKER_JSON_PATH + + if not tracker_path.exists(): return { "last_tracked_version": None, "last_checked": None } try: - with open(TRACKER_JSON_PATH, 'r', encoding='utf-8') as f: + with open(tracker_path, 'r', encoding='utf-8') as f: return json.load(f) except json.JSONDecodeError as e: print(f"Error: Invalid JSON in tracker file: {e}", file=sys.stderr) @@ -179,20 +185,24 @@ def load_tracker() -> dict: } -def save_tracker(version: str): +def save_tracker(version: str, tracker_path: Optional[Path] = None): """ Save the current version to the tracker JSON file. Args: version: Version string to save + tracker_path: Optional path to tracker file. Defaults to TRACKER_JSON_PATH. """ + if tracker_path is None: + tracker_path = TRACKER_JSON_PATH + tracker_data = { "last_tracked_version": version, "last_checked": datetime.now(timezone.utc).isoformat() } try: - with open(TRACKER_JSON_PATH, 'w', encoding='utf-8') as f: + with open(tracker_path, 'w', encoding='utf-8') as f: json.dump(tracker_data, f, indent=2) except Exception as e: print(f"Error writing tracker file: {e}", file=sys.stderr) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e66f6c1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Tests for Noble Documentation Version Tracker + diff --git a/tests/test_check_noble_version.py b/tests/test_check_noble_version.py new file mode 100644 index 0000000..0151f7b --- /dev/null +++ b/tests/test_check_noble_version.py @@ -0,0 +1,245 @@ +"""Unit tests for check_noble_version.py""" + +import json +import pytest +import tempfile +from pathlib import Path + +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from check_noble_version import ( + parse_version, + compare_versions, + get_latest_version_from_upgrades, + load_tracker, + save_tracker, +) + + +class TestParseVersion: + """Tests for parse_version function""" + + def test_parse_version_with_v_prefix(self): + """Test parsing version with 'v' prefix""" + assert parse_version("v11.0.0") == (11, 0, 0) + assert parse_version("v1.2.3") == (1, 2, 3) + assert parse_version("v10.5.20") == (10, 5, 20) + + def test_parse_version_without_v_prefix(self): + """Test parsing version without 'v' prefix""" + assert parse_version("11.0.0") == (11, 0, 0) + assert parse_version("1.2.3") == (1, 2, 3) + + def test_parse_version_raises_on_suffix(self): + """Test that parse_version raises ValueError for versions with suffixes""" + with pytest.raises(ValueError, match="Version with suffix detected"): + parse_version("v11.0.0-rc1") + + with pytest.raises(ValueError, match="Version with suffix detected"): + parse_version("v11.0.0-beta") + + with pytest.raises(ValueError, match="Version with suffix detected"): + parse_version("v11.0.0-alpha") + + def test_parse_version_raises_on_invalid_format(self): + """Test that parse_version raises ValueError for invalid formats""" + with pytest.raises(ValueError, match="Invalid version format"): + parse_version("11.0") # Missing patch version + + with pytest.raises(ValueError, match="Invalid version format"): + parse_version("11") # Only major version + + with pytest.raises(ValueError, match="Invalid version format"): + parse_version("11.0.0.1") # Too many parts + + def test_parse_version_raises_on_non_integer(self): + """Test that parse_version raises ValueError for non-integer components""" + with pytest.raises(ValueError, match="All version components must be integers"): + parse_version("v11.0.a") + + with pytest.raises(ValueError, match="All version components must be integers"): + parse_version("v11.x.0") + + +class TestCompareVersions: + """Tests for compare_versions function""" + + def test_compare_versions_equal(self): + """Test comparing equal versions""" + assert compare_versions("v11.0.0", "v11.0.0") == 0 + assert compare_versions("v1.2.3", "v1.2.3") == 0 + assert compare_versions("11.0.0", "v11.0.0") == 0 + + def test_compare_versions_less_than(self): + """Test comparing when first version is less than second""" + assert compare_versions("v10.0.0", "v11.0.0") == -1 + assert compare_versions("v11.0.0", "v11.1.0") == -1 + assert compare_versions("v11.0.0", "v11.0.1") == -1 + assert compare_versions("v1.0.0", "v2.0.0") == -1 + + def test_compare_versions_greater_than(self): + """Test comparing when first version is greater than second""" + assert compare_versions("v11.0.0", "v10.0.0") == 1 + assert compare_versions("v11.1.0", "v11.0.0") == 1 + assert compare_versions("v11.0.1", "v11.0.0") == 1 + assert compare_versions("v2.0.0", "v1.0.0") == 1 + + def test_compare_versions_with_suffix_raises(self): + """Test that compare_versions raises ValueError when versions have suffixes""" + with pytest.raises(ValueError, match="Error comparing versions"): + compare_versions("v11.0.0-rc1", "v11.0.0") + + with pytest.raises(ValueError, match="Error comparing versions"): + compare_versions("v11.0.0", "v11.0.0-rc1") + + +class TestGetLatestVersionFromUpgrades: + """Tests for get_latest_version_from_upgrades function""" + + def test_get_latest_version_simple(self): + """Test extracting latest version from simple MDX content""" + content = """ +| Tag | Name | +|-----|------| +| [`v1.0.0`](url) | Version 1 | +| [`v2.0.0`](url) | Version 2 | +| [`v3.0.0`](url) | Version 3 | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result == "v3.0.0" + + path.unlink() + + def test_get_latest_version_unordered(self): + """Test extracting latest version when versions are not in order""" + content = """ +| Tag | Name | +|-----|------| +| [`v5.0.0`](url) | Version 5 | +| [`v2.0.0`](url) | Version 2 | +| [`v10.0.0`](url) | Version 10 | +| [`v3.0.0`](url) | Version 3 | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result == "v10.0.0" + + path.unlink() + + def test_get_latest_version_with_suffix_returns_none(self): + """Test that versions with suffixes cause function to return None""" + content = """ +| Tag | Name | +|-----|------| +| [`v1.0.0`](url) | Version 1 | +| [`v2.0.0-rc1`](url) | RC Version | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result is None + + path.unlink() + + def test_get_latest_version_no_versions_returns_none(self): + """Test that function returns None when no versions are found""" + content = """ +| Tag | Name | +|-----|------| +| Some content without versions | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result is None + + path.unlink() + + def test_get_latest_version_file_not_found_returns_none(self): + """Test that function returns None when file doesn't exist""" + non_existent_path = Path("/tmp/non_existent_file_12345.mdx") + result = get_latest_version_from_upgrades(non_existent_path) + assert result is None + + +class TestLoadTracker: + """Tests for load_tracker function""" + + def test_load_tracker_file_not_exists(self): + """Test loading tracker when file doesn't exist""" + non_existent_path = Path("/tmp/non_existent_tracker_12345.json") + result = load_tracker(non_existent_path) + assert result == { + "last_tracked_version": None, + "last_checked": None + } + + def test_load_tracker_valid_file(self): + """Test loading tracker from valid JSON file""" + tracker_data = { + "last_tracked_version": "v11.0.0", + "last_checked": "2024-01-01T00:00:00Z" + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(tracker_data, f) + f.flush() + path = Path(f.name) + + result = load_tracker(path) + assert result == tracker_data + + path.unlink() + + def test_load_tracker_invalid_json(self): + """Test loading tracker with invalid JSON returns default""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + f.write("invalid json content {") + f.flush() + path = Path(f.name) + + result = load_tracker(path) + assert result == { + "last_tracked_version": None, + "last_checked": None + } + + path.unlink() + + +class TestSaveTracker: + """Tests for save_tracker function""" + + def test_save_tracker_creates_file(self): + """Test that save_tracker creates a file with correct content""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + path = Path(f.name) + path.unlink() # Delete the file so we can test creation + + save_tracker("v11.0.0", path) + + assert path.exists() + with open(path, 'r') as f: + data = json.load(f) + assert data["last_tracked_version"] == "v11.0.0" + assert "last_checked" in data + assert data["last_checked"] is not None + + path.unlink() + From 61179a64daa1f6266ff61b5d894f819885047370 Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 15:59:01 +0100 Subject: [PATCH 5/7] add logic to check diff between latest checked version and latest mainnet version --- scripts/check_noble_version.py | 161 +++++++++++++++++++++++++++++- tests/test_check_noble_version.py | 75 ++++++++++++++ 2 files changed, 233 insertions(+), 3 deletions(-) diff --git a/scripts/check_noble_version.py b/scripts/check_noble_version.py index 724bb6e..5e275e8 100755 --- a/scripts/check_noble_version.py +++ b/scripts/check_noble_version.py @@ -11,9 +11,10 @@ import re import sys from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Tuple, Dict, Any from datetime import datetime, timezone +import requests # Configuration REPO_ROOT = Path(__file__).parent.parent @@ -208,6 +209,149 @@ def save_tracker(version: str, tracker_path: Optional[Path] = None): print(f"Error writing tracker file: {e}", file=sys.stderr) +def get_diff_between_tags(repo: str, base_tag: str, head_tag: str) -> Optional[Dict[str, Any]]: + """ + Get the diff between two tags from GitHub API. + + Args: + repo: Repository in format 'owner/repo' + base_tag: Base tag (e.g., 'v11.0.0') + head_tag: Head tag (e.g., 'v12.0.0') + + Returns: + Dictionary with comparison data from GitHub API, or None on error + """ + url = f"https://api.github.com/repos/{repo}/compare/{base_tag}...{head_tag}" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error fetching diff from GitHub API: {e}", file=sys.stderr) + if hasattr(e, 'response') and e.response is not None: + try: + error_data = e.response.json() + if 'message' in error_data: + print(f"GitHub API error: {error_data['message']}", file=sys.stderr) + except (ValueError, KeyError): + pass + return None + + +def format_diff_summary(comparison_data: Dict[str, Any]) -> str: + """ + Format the GitHub comparison data into a readable summary. + + Args: + comparison_data: Dictionary from GitHub compare API + + Returns: + Formatted string summary + """ + if not comparison_data: + return "No diff data available." + + summary_lines = [] + + # Basic info + status = comparison_data.get('status', 'unknown') + ahead_by = comparison_data.get('ahead_by', 0) + behind_by = comparison_data.get('behind_by', 0) + total_commits = comparison_data.get('total_commits', 0) + + summary_lines.append(f"Comparison Status: {status}") + summary_lines.append(f"Commits ahead: {ahead_by}, behind: {behind_by}, total: {total_commits}") + summary_lines.append("") + + # Files changed + files = comparison_data.get('files', []) + if files: + summary_lines.append(f"Files changed: {len(files)}") + summary_lines.append("") + + # Group by status + added = [f for f in files if f.get('status') == 'added'] + removed = [f for f in files if f.get('status') == 'removed'] + modified = [f for f in files if f.get('status') == 'modified'] + renamed = [f for f in files if f.get('status') == 'renamed'] + + if added: + summary_lines.append(f"Added files ({len(added)}):") + for f in added[:10]: # Limit to first 10 + summary_lines.append(f" + {f.get('filename', 'unknown')}") + if len(added) > 10: + summary_lines.append(f" ... and {len(added) - 10} more") + summary_lines.append("") + + if removed: + summary_lines.append(f"Removed files ({len(removed)}):") + for f in removed[:10]: + summary_lines.append(f" - {f.get('filename', 'unknown')}") + if len(removed) > 10: + summary_lines.append(f" ... and {len(removed) - 10} more") + summary_lines.append("") + + if modified: + summary_lines.append(f"Modified files ({len(modified)}):") + for f in modified[:20]: # Show more modified files + changes = f.get('changes', 0) + additions = f.get('additions', 0) + deletions = f.get('deletions', 0) + summary_lines.append(f" ~ {f.get('filename', 'unknown')} (+{additions}, -{deletions}, {changes} total)") + if len(modified) > 20: + summary_lines.append(f" ... and {len(modified) - 20} more") + summary_lines.append("") + + if renamed: + summary_lines.append(f"Renamed files ({len(renamed)}):") + for f in renamed[:10]: + old_name = f.get('previous_filename', 'unknown') + new_name = f.get('filename', 'unknown') + summary_lines.append(f" → {old_name} → {new_name}") + if len(renamed) > 10: + summary_lines.append(f" ... and {len(renamed) - 10} more") + summary_lines.append("") + + # Filter for relevant files (modules, configs, etc.) + # + # TODO: double check this is the correct relevant list + relevant_files = [ + f for f in files + if any(keyword in f.get('filename', '').lower() for keyword in [ + 'module', 'x/', 'proto', 'upgrade', 'migration', 'changelog', 'release' + ]) + ] + + if relevant_files: + summary_lines.append("Potentially relevant files for documentation:") + for f in relevant_files[:15]: + summary_lines.append(f" • {f.get('filename', 'unknown')}") + if len(relevant_files) > 15: + summary_lines.append(f" ... and {len(relevant_files) - 15} more") + summary_lines.append("") + + # Commits summary + commits = comparison_data.get('commits', []) + if commits: + summary_lines.append(f"Recent commits ({min(5, len(commits))} of {len(commits)}):") + for commit in commits[:5]: + message = commit.get('commit', {}).get('message', '').split('\n')[0] + sha = commit.get('sha', '')[:7] + author = commit.get('commit', {}).get('author', {}).get('name', 'unknown') + summary_lines.append(f" [{sha}] {message} ({author})") + if len(commits) > 5: + summary_lines.append(f" ... and {len(commits) - 5} more commits") + summary_lines.append("") + + # Add comparison URL + html_url = comparison_data.get('html_url', '') + if html_url: + summary_lines.append(f"Full comparison: {html_url}") + + return "\n".join(summary_lines) + + def main(): """Main function to check version and compare with tracker.""" print("Noble Documentation Version Tracker") @@ -248,8 +392,19 @@ def main(): print(f" Last tracked: {last_tracked}") print(f" Latest in docs: {latest_version}") print(f"\n The documentation has been updated with a new version.") - print(f" Next step: Generate diff between {last_tracked} and {latest_version}") - # TODO: Generate diff and LLM suggestions + print(f"\n Generating diff between {last_tracked} and {latest_version}...") + + diff_data = get_diff_between_tags(GITHUB_REPO, last_tracked, latest_version) + if diff_data: + print("\n" + "=" * 50) + print("DIFF SUMMARY") + print("=" * 50) + print(format_diff_summary(diff_data)) + print("=" * 50) + else: + print(f"\n ⚠ Could not fetch diff from GitHub API.") + print(f" You can view the comparison manually at:") + print(f" https://github.com/{GITHUB_REPO}/compare/{last_tracked}...{latest_version}") else: print(f"\n⚠ Warning: Last tracked version ({last_tracked}) is newer than latest in docs ({latest_version})") print(" This shouldn't happen - the docs may have been reverted.") diff --git a/tests/test_check_noble_version.py b/tests/test_check_noble_version.py index 0151f7b..4463fda 100644 --- a/tests/test_check_noble_version.py +++ b/tests/test_check_noble_version.py @@ -14,6 +14,8 @@ get_latest_version_from_upgrades, load_tracker, save_tracker, + get_diff_between_tags, + format_diff_summary, ) @@ -243,3 +245,76 @@ def test_save_tracker_creates_file(self): path.unlink() + +class TestGetDiffBetweenTags: + """Tests for get_diff_between_tags function""" + + def test_get_diff_between_tags_invalid_repo(self): + """Test that function returns None for invalid repository""" + result = get_diff_between_tags("invalid/repo", "v1.0.0", "v2.0.0") + assert result is None + + def test_get_diff_between_tags_invalid_tags(self): + """Test that function returns None for invalid tags""" + result = get_diff_between_tags("noble-assets/noble", "v999.999.999", "v999.999.998") + assert result is None + + +class TestFormatDiffSummary: + """Tests for format_diff_summary function""" + + def test_format_diff_summary_empty(self): + """Test formatting empty diff data""" + result = format_diff_summary({}) + assert "No diff data available" in result + + def test_format_diff_summary_basic(self): + """Test formatting basic diff data""" + comparison_data = { + "status": "ahead", + "ahead_by": 5, + "behind_by": 0, + "total_commits": 5, + "files": [ + {"filename": "x/module/file.go", "status": "modified", "additions": 10, "deletions": 5, "changes": 15}, + {"filename": "test.go", "status": "added", "additions": 20, "deletions": 0, "changes": 20}, + ], + "commits": [ + { + "sha": "abc1234", + "commit": { + "message": "Add new feature", + "author": {"name": "Test Author"} + } + } + ], + "html_url": "https://github.com/noble-assets/noble/compare/v1...v2" + } + result = format_diff_summary(comparison_data) + assert "Comparison Status: ahead" in result + assert "Commits ahead: 5" in result + assert "Files changed: 2" in result + assert "x/module/file.go" in result + assert "Add new feature" in result + assert "https://github.com/noble-assets/noble/compare/v1...v2" in result + + def test_format_diff_summary_relevant_files(self): + """Test that relevant files are highlighted""" + comparison_data = { + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "total_commits": 1, + "files": [ + {"filename": "x/module/file.go", "status": "modified", "additions": 10, "deletions": 5, "changes": 15}, + {"filename": "proto/upgrade.proto", "status": "modified", "additions": 5, "deletions": 2, "changes": 7}, + {"filename": "unrelated.go", "status": "modified", "additions": 1, "deletions": 1, "changes": 2}, + ], + "commits": [], + "html_url": "" + } + result = format_diff_summary(comparison_data) + assert "Potentially relevant files for documentation:" in result + assert "x/module/file.go" in result + assert "proto/upgrade.proto" in result + From 3f3024e68fd6b464b8563b871f7e8480b2eda89c Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 16:34:07 +0100 Subject: [PATCH 6/7] move tests next into scripts sub dir and refactor code into separate files --- .mise.toml | 2 +- .noble_version_tracker.json | 2 +- scripts/check_noble_version.py | 415 +------------------- scripts/check_noble_version/__init__.py | 32 ++ scripts/check_noble_version/cli.py | 82 ++++ scripts/check_noble_version/config.py | 16 + scripts/check_noble_version/github.py | 150 +++++++ scripts/check_noble_version/parser.py | 70 ++++ scripts/check_noble_version/test_github.py | 77 ++++ scripts/check_noble_version/test_parser.py | 91 +++++ scripts/check_noble_version/test_tracker.py | 74 ++++ scripts/check_noble_version/test_version.py | 83 ++++ scripts/check_noble_version/tracker.py | 69 ++++ scripts/check_noble_version/version.py | 70 ++++ tests/__init__.py | 2 - tests/test_check_noble_version.py | 320 --------------- 16 files changed, 818 insertions(+), 737 deletions(-) create mode 100644 scripts/check_noble_version/__init__.py create mode 100644 scripts/check_noble_version/cli.py create mode 100644 scripts/check_noble_version/config.py create mode 100644 scripts/check_noble_version/github.py create mode 100644 scripts/check_noble_version/parser.py create mode 100644 scripts/check_noble_version/test_github.py create mode 100644 scripts/check_noble_version/test_parser.py create mode 100644 scripts/check_noble_version/test_tracker.py create mode 100644 scripts/check_noble_version/test_version.py create mode 100644 scripts/check_noble_version/tracker.py create mode 100644 scripts/check_noble_version/version.py delete mode 100644 tests/__init__.py delete mode 100644 tests/test_check_noble_version.py diff --git a/.mise.toml b/.mise.toml index ac878c4..8a11402 100644 --- a/.mise.toml +++ b/.mise.toml @@ -30,5 +30,5 @@ run = "uv run python scripts/check_noble_version.py" [tasks.test] description = "Run pytest unit tests" -run = "uv run pytest tests/ -v" +run = "uv run pytest scripts/check_noble_version/test_*.py -v" diff --git a/.noble_version_tracker.json b/.noble_version_tracker.json index f6774bc..cfe103e 100644 --- a/.noble_version_tracker.json +++ b/.noble_version_tracker.json @@ -1,4 +1,4 @@ { - "last_tracked_version": "v11.0.0", + "last_tracked_version": "v10.0.0", "last_checked": "2025-11-14T14:36:58.293690+00:00" } \ No newline at end of file diff --git a/scripts/check_noble_version.py b/scripts/check_noble_version.py index 5e275e8..f25e29f 100755 --- a/scripts/check_noble_version.py +++ b/scripts/check_noble_version.py @@ -2,421 +2,10 @@ """ Noble Documentation Version Tracker -This script checks the latest Noble version from the chain upgrades documentation -and compares it with the last tracked version. If there's a mismatch, it can -generate a diff and suggest documentation updates. +Entry point script that delegates to the package CLI. """ -import json -import re -import sys -from pathlib import Path -from typing import Optional, Tuple, Dict, Any -from datetime import datetime, timezone - -import requests - -# Configuration -REPO_ROOT = Path(__file__).parent.parent -MAINNET_MDX_PATH = REPO_ROOT / "docs" / "build" / "chain-upgrades" / "mainnet.mdx" -TRACKER_JSON_PATH = REPO_ROOT / ".noble_version_tracker.json" -GITHUB_REPO = "noble-assets/noble" - - -def parse_version(version_str: str) -> Tuple[int, int, int]: - """ - Parse a version string (e.g., 'v11.0.0') into a tuple of (major, minor, patch). - - Args: - version_str: Version string in format 'vX.Y.Z' or 'X.Y.Z' - - Returns: - Tuple of (major, minor, patch) integers - - Raises: - ValueError: If version format is invalid or contains suffixes (e.g., -rc1) - """ - # Check for version suffixes (e.g., -rc1, -beta, -alpha) - if '-' in version_str: - raise ValueError( - f"Version with suffix detected: '{version_str}'\n" - f"This tool currently does not support version suffixes (e.g., -rc1, -beta, -alpha).\n" - f"Please use only release versions in the format 'vX.Y.Z' (e.g., 'v11.0.0')." - ) - - # Remove 'v' prefix if present - version_str = version_str.lstrip('v') - parts = version_str.split('.') - if len(parts) != 3: - raise ValueError( - f"Invalid version format: '{version_str}'\n" - f"Expected format: 'vX.Y.Z' or 'X.Y.Z' where X, Y, Z are integers." - ) - - try: - return (int(parts[0]), int(parts[1]), int(parts[2])) - except ValueError as e: - raise ValueError( - f"Invalid version format: '{version_str}'\n" - f"All version components must be integers. Error: {e}" - ) - - -def compare_versions(version1: str, version2: str) -> int: - """ - Compare two version strings. - - Returns: - -1 if version1 < version2 - 0 if version1 == version2 - 1 if version1 > version2 - - Raises: - ValueError: If either version format is invalid or contains suffixes - """ - try: - v1 = parse_version(version1) - v2 = parse_version(version2) - except ValueError as e: - # Re-raise with context about which version failed - raise ValueError(f"Error comparing versions '{version1}' and '{version2}':\n{e}") - - if v1 < v2: - return -1 - elif v1 > v2: - return 1 - else: - return 0 - - -def get_latest_version_from_upgrades(mdx_path: Path) -> Optional[str]: - """ - Parse the mainnet.mdx file and extract the latest version from the upgrades table. - - Args: - mdx_path: Path to the mainnet.mdx file - - Returns: - Latest version string (e.g., 'v11.0.0') or None if not found - """ - try: - with open(mdx_path, 'r', encoding='utf-8') as f: - content = f.read() - except FileNotFoundError: - print(f"Error: File not found: {mdx_path}", file=sys.stderr) - return None - except Exception as e: - print(f"Error reading file {mdx_path}: {e}", file=sys.stderr) - return None - - # Pattern to match version tags in markdown links: [`vX.Y.Z`](url) or [`vX.Y.Z-suffix`](url) - # This matches patterns like [`v11.0.0`](https://github.com/...) or [`v11.0.0-rc1`](...) - # We capture versions with optional suffixes to detect and handle them - version_pattern = r'\[`(v\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+)?)`\]' - - # Find all version matches - versions = re.findall(version_pattern, content) - - if not versions: - print("Warning: No versions found in the upgrades table", file=sys.stderr) - return None - - # Check for versions with suffixes and report them - versions_with_suffixes = [v for v in versions if '-' in v] - if versions_with_suffixes: - print("\n⚠ Warning: Found versions with suffixes in the upgrades table:", file=sys.stderr) - for v in versions_with_suffixes: - print(f" - {v}", file=sys.stderr) - print("\nThis tool cannot handle version suffixes (e.g., -rc1, -beta, -alpha).", file=sys.stderr) - print("Please update the documentation to use only release versions.", file=sys.stderr) - print("\nError details:", file=sys.stderr) - try: - # Try to parse one to trigger the error message - parse_version(versions_with_suffixes[0]) - except ValueError as e: - print(f"{e}", file=sys.stderr) - return None - - # Find the latest version by comparing all found versions - try: - latest_version = versions[0] - for version in versions[1:]: - if compare_versions(version, latest_version) > 0: - latest_version = version - except ValueError as e: - print(f"\nError: Failed to compare versions: {e}", file=sys.stderr) - return None - - return latest_version - - -def load_tracker(tracker_path: Optional[Path] = None) -> dict: - """ - Load the version tracker JSON file. - - Args: - tracker_path: Optional path to tracker file. Defaults to TRACKER_JSON_PATH. - - Returns: - Dictionary with tracker data, or default structure if file doesn't exist - """ - if tracker_path is None: - tracker_path = TRACKER_JSON_PATH - - if not tracker_path.exists(): - return { - "last_tracked_version": None, - "last_checked": None - } - - try: - with open(tracker_path, 'r', encoding='utf-8') as f: - return json.load(f) - except json.JSONDecodeError as e: - print(f"Error: Invalid JSON in tracker file: {e}", file=sys.stderr) - return { - "last_tracked_version": None, - "last_checked": None - } - except Exception as e: - print(f"Error reading tracker file: {e}", file=sys.stderr) - return { - "last_tracked_version": None, - "last_checked": None - } - - -def save_tracker(version: str, tracker_path: Optional[Path] = None): - """ - Save the current version to the tracker JSON file. - - Args: - version: Version string to save - tracker_path: Optional path to tracker file. Defaults to TRACKER_JSON_PATH. - """ - if tracker_path is None: - tracker_path = TRACKER_JSON_PATH - - tracker_data = { - "last_tracked_version": version, - "last_checked": datetime.now(timezone.utc).isoformat() - } - - try: - with open(tracker_path, 'w', encoding='utf-8') as f: - json.dump(tracker_data, f, indent=2) - except Exception as e: - print(f"Error writing tracker file: {e}", file=sys.stderr) - - -def get_diff_between_tags(repo: str, base_tag: str, head_tag: str) -> Optional[Dict[str, Any]]: - """ - Get the diff between two tags from GitHub API. - - Args: - repo: Repository in format 'owner/repo' - base_tag: Base tag (e.g., 'v11.0.0') - head_tag: Head tag (e.g., 'v12.0.0') - - Returns: - Dictionary with comparison data from GitHub API, or None on error - """ - url = f"https://api.github.com/repos/{repo}/compare/{base_tag}...{head_tag}" - - try: - response = requests.get(url, timeout=30) - response.raise_for_status() - return response.json() - except requests.exceptions.RequestException as e: - print(f"Error fetching diff from GitHub API: {e}", file=sys.stderr) - if hasattr(e, 'response') and e.response is not None: - try: - error_data = e.response.json() - if 'message' in error_data: - print(f"GitHub API error: {error_data['message']}", file=sys.stderr) - except (ValueError, KeyError): - pass - return None - - -def format_diff_summary(comparison_data: Dict[str, Any]) -> str: - """ - Format the GitHub comparison data into a readable summary. - - Args: - comparison_data: Dictionary from GitHub compare API - - Returns: - Formatted string summary - """ - if not comparison_data: - return "No diff data available." - - summary_lines = [] - - # Basic info - status = comparison_data.get('status', 'unknown') - ahead_by = comparison_data.get('ahead_by', 0) - behind_by = comparison_data.get('behind_by', 0) - total_commits = comparison_data.get('total_commits', 0) - - summary_lines.append(f"Comparison Status: {status}") - summary_lines.append(f"Commits ahead: {ahead_by}, behind: {behind_by}, total: {total_commits}") - summary_lines.append("") - - # Files changed - files = comparison_data.get('files', []) - if files: - summary_lines.append(f"Files changed: {len(files)}") - summary_lines.append("") - - # Group by status - added = [f for f in files if f.get('status') == 'added'] - removed = [f for f in files if f.get('status') == 'removed'] - modified = [f for f in files if f.get('status') == 'modified'] - renamed = [f for f in files if f.get('status') == 'renamed'] - - if added: - summary_lines.append(f"Added files ({len(added)}):") - for f in added[:10]: # Limit to first 10 - summary_lines.append(f" + {f.get('filename', 'unknown')}") - if len(added) > 10: - summary_lines.append(f" ... and {len(added) - 10} more") - summary_lines.append("") - - if removed: - summary_lines.append(f"Removed files ({len(removed)}):") - for f in removed[:10]: - summary_lines.append(f" - {f.get('filename', 'unknown')}") - if len(removed) > 10: - summary_lines.append(f" ... and {len(removed) - 10} more") - summary_lines.append("") - - if modified: - summary_lines.append(f"Modified files ({len(modified)}):") - for f in modified[:20]: # Show more modified files - changes = f.get('changes', 0) - additions = f.get('additions', 0) - deletions = f.get('deletions', 0) - summary_lines.append(f" ~ {f.get('filename', 'unknown')} (+{additions}, -{deletions}, {changes} total)") - if len(modified) > 20: - summary_lines.append(f" ... and {len(modified) - 20} more") - summary_lines.append("") - - if renamed: - summary_lines.append(f"Renamed files ({len(renamed)}):") - for f in renamed[:10]: - old_name = f.get('previous_filename', 'unknown') - new_name = f.get('filename', 'unknown') - summary_lines.append(f" → {old_name} → {new_name}") - if len(renamed) > 10: - summary_lines.append(f" ... and {len(renamed) - 10} more") - summary_lines.append("") - - # Filter for relevant files (modules, configs, etc.) - # - # TODO: double check this is the correct relevant list - relevant_files = [ - f for f in files - if any(keyword in f.get('filename', '').lower() for keyword in [ - 'module', 'x/', 'proto', 'upgrade', 'migration', 'changelog', 'release' - ]) - ] - - if relevant_files: - summary_lines.append("Potentially relevant files for documentation:") - for f in relevant_files[:15]: - summary_lines.append(f" • {f.get('filename', 'unknown')}") - if len(relevant_files) > 15: - summary_lines.append(f" ... and {len(relevant_files) - 15} more") - summary_lines.append("") - - # Commits summary - commits = comparison_data.get('commits', []) - if commits: - summary_lines.append(f"Recent commits ({min(5, len(commits))} of {len(commits)}):") - for commit in commits[:5]: - message = commit.get('commit', {}).get('message', '').split('\n')[0] - sha = commit.get('sha', '')[:7] - author = commit.get('commit', {}).get('author', {}).get('name', 'unknown') - summary_lines.append(f" [{sha}] {message} ({author})") - if len(commits) > 5: - summary_lines.append(f" ... and {len(commits) - 5} more commits") - summary_lines.append("") - - # Add comparison URL - html_url = comparison_data.get('html_url', '') - if html_url: - summary_lines.append(f"Full comparison: {html_url}") - - return "\n".join(summary_lines) - - -def main(): - """Main function to check version and compare with tracker.""" - print("Noble Documentation Version Tracker") - print("=" * 50) - - # Get latest version from upgrades table - print(f"\nReading upgrades from: {MAINNET_MDX_PATH}") - latest_version = get_latest_version_from_upgrades(MAINNET_MDX_PATH) - - if not latest_version: - print("Error: Could not determine latest version from upgrades table", file=sys.stderr) - sys.exit(1) - - print(f"Latest version in upgrades table: {latest_version}") - - # Load tracker - tracker = load_tracker() - last_tracked = tracker.get("last_tracked_version") - - if last_tracked: - print(f"Last tracked version: {last_tracked}") - - # Compare versions - try: - comparison = compare_versions(last_tracked, latest_version) - except ValueError as e: - print(f"\n❌ Error: {e}", file=sys.stderr) - print("\nPlease ensure both versions are in the format 'vX.Y.Z' without suffixes.", file=sys.stderr) - sys.exit(1) - - if comparison == 0: - print("\n✓ Versions match! Documentation is up to date.") - # Update last_checked timestamp - save_tracker(latest_version) - sys.exit(0) - elif comparison < 0: - print(f"\n⚠ Version mismatch detected!") - print(f" Last tracked: {last_tracked}") - print(f" Latest in docs: {latest_version}") - print(f"\n The documentation has been updated with a new version.") - print(f"\n Generating diff between {last_tracked} and {latest_version}...") - - diff_data = get_diff_between_tags(GITHUB_REPO, last_tracked, latest_version) - if diff_data: - print("\n" + "=" * 50) - print("DIFF SUMMARY") - print("=" * 50) - print(format_diff_summary(diff_data)) - print("=" * 50) - else: - print(f"\n ⚠ Could not fetch diff from GitHub API.") - print(f" You can view the comparison manually at:") - print(f" https://github.com/{GITHUB_REPO}/compare/{last_tracked}...{latest_version}") - else: - print(f"\n⚠ Warning: Last tracked version ({last_tracked}) is newer than latest in docs ({latest_version})") - print(" This shouldn't happen - the docs may have been reverted.") - else: - print("\nNo previous version tracked (first run)") - print(f"Setting initial tracked version to: {latest_version}") - save_tracker(latest_version) - print("\n✓ Tracker initialized. Run again to check for updates.") - - print("\n" + "=" * 50) - +from check_noble_version.cli import main if __name__ == "__main__": main() - diff --git a/scripts/check_noble_version/__init__.py b/scripts/check_noble_version/__init__.py new file mode 100644 index 0000000..82180bc --- /dev/null +++ b/scripts/check_noble_version/__init__.py @@ -0,0 +1,32 @@ +""" +Noble Documentation Version Tracker + +This package provides functionality to track Noble chain versions and +generate diffs between versions for documentation updates. +""" + +from check_noble_version.version import parse_version, compare_versions +from check_noble_version.parser import get_latest_version_from_upgrades +from check_noble_version.tracker import load_tracker, save_tracker +from check_noble_version.github import get_diff_between_tags, format_diff_summary +from check_noble_version.config import ( + REPO_ROOT, + MAINNET_MDX_PATH, + TRACKER_JSON_PATH, + GITHUB_REPO, +) + +__all__ = [ + "parse_version", + "compare_versions", + "get_latest_version_from_upgrades", + "load_tracker", + "save_tracker", + "get_diff_between_tags", + "format_diff_summary", + "REPO_ROOT", + "MAINNET_MDX_PATH", + "TRACKER_JSON_PATH", + "GITHUB_REPO", +] + diff --git a/scripts/check_noble_version/cli.py b/scripts/check_noble_version/cli.py new file mode 100644 index 0000000..9f8867c --- /dev/null +++ b/scripts/check_noble_version/cli.py @@ -0,0 +1,82 @@ +"""Command-line interface for the version tracker.""" + +import sys + +from check_noble_version.config import ( + MAINNET_MDX_PATH, + GITHUB_REPO, +) +from check_noble_version.parser import get_latest_version_from_upgrades +from check_noble_version.tracker import load_tracker, save_tracker +from check_noble_version.version import compare_versions +from check_noble_version.github import get_diff_between_tags, format_diff_summary + + +def main(): + """Main function to check version and compare with tracker.""" + print("Noble Documentation Version Tracker") + print("=" * 50) + + # Get latest version from upgrades table + print(f"\nReading upgrades from: {MAINNET_MDX_PATH}") + latest_version = get_latest_version_from_upgrades(MAINNET_MDX_PATH) + + if not latest_version: + print("Error: Could not determine latest version from upgrades table", file=sys.stderr) + sys.exit(1) + + print(f"Latest version in upgrades table: {latest_version}") + + # Load tracker + tracker = load_tracker() + last_tracked = tracker.get("last_tracked_version") + + if last_tracked: + print(f"Last tracked version: {last_tracked}") + + # Compare versions + try: + comparison = compare_versions(last_tracked, latest_version) + except ValueError as e: + print(f"\n❌ Error: {e}", file=sys.stderr) + print("\nPlease ensure both versions are in the format 'vX.Y.Z' without suffixes.", file=sys.stderr) + sys.exit(1) + + if comparison == 0: + print("\n✓ Versions match! Documentation is up to date.") + # Update last_checked timestamp + save_tracker(latest_version) + sys.exit(0) + elif comparison < 0: + print(f"\n⚠ Version mismatch detected!") + print(f" Last tracked: {last_tracked}") + print(f" Latest in docs: {latest_version}") + print(f"\n The documentation has been updated with a new version.") + print(f"\n Generating diff between {last_tracked} and {latest_version}...") + + diff_data = get_diff_between_tags(GITHUB_REPO, last_tracked, latest_version) + if diff_data: + print("\n" + "=" * 50) + print("DIFF SUMMARY") + print("=" * 50) + print(format_diff_summary(diff_data)) + print("=" * 50) + else: + print(f"\n ⚠ Could not fetch diff from GitHub API.") + print(f" You can view the comparison manually at:") + print(f" https://github.com/{GITHUB_REPO}/compare/{last_tracked}...{latest_version}") + else: + print(f"\n⚠ Warning: Last tracked version ({last_tracked}) is newer than latest in docs ({latest_version})") + print(" This shouldn't happen - the docs may have been reverted.") + else: + print("\nNo previous version tracked (first run)") + print(f"Setting initial tracked version to: {latest_version}") + save_tracker(latest_version) + print("\n✓ Tracker initialized. Run again to check for updates.") + + print("\n" + "=" * 50) + + +if __name__ == "__main__": + main() + diff --git a/scripts/check_noble_version/config.py b/scripts/check_noble_version/config.py new file mode 100644 index 0000000..7e80b7a --- /dev/null +++ b/scripts/check_noble_version/config.py @@ -0,0 +1,16 @@ +"""Configuration constants for the version tracker.""" + +from pathlib import Path + +# Repository root (parent of scripts directory) +REPO_ROOT = Path(__file__).parent.parent.parent + +# Path to mainnet upgrades documentation +MAINNET_MDX_PATH = REPO_ROOT / "docs" / "build" / "chain-upgrades" / "mainnet.mdx" + +# Path to version tracker JSON file +TRACKER_JSON_PATH = REPO_ROOT / ".noble_version_tracker.json" + +# GitHub repository +GITHUB_REPO = "noble-assets/noble" + diff --git a/scripts/check_noble_version/github.py b/scripts/check_noble_version/github.py new file mode 100644 index 0000000..8c85c54 --- /dev/null +++ b/scripts/check_noble_version/github.py @@ -0,0 +1,150 @@ +"""GitHub API integration for fetching diffs.""" + +import sys +from typing import Optional, Dict, Any + +import requests + + +def get_diff_between_tags(repo: str, base_tag: str, head_tag: str) -> Optional[Dict[str, Any]]: + """ + Get the diff between two tags from GitHub API. + + Args: + repo: Repository in format 'owner/repo' + base_tag: Base tag (e.g., 'v11.0.0') + head_tag: Head tag (e.g., 'v12.0.0') + + Returns: + Dictionary with comparison data from GitHub API, or None on error + """ + url = f"https://api.github.com/repos/{repo}/compare/{base_tag}...{head_tag}" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error fetching diff from GitHub API: {e}", file=sys.stderr) + if hasattr(e, 'response') and e.response is not None: + try: + error_data = e.response.json() + if 'message' in error_data: + print(f"GitHub API error: {error_data['message']}", file=sys.stderr) + except (ValueError, KeyError): + pass + return None + + +def format_diff_summary(comparison_data: Dict[str, Any]) -> str: + """ + Format the GitHub comparison data into a readable summary. + + Args: + comparison_data: Dictionary from GitHub compare API + + Returns: + Formatted string summary + """ + if not comparison_data: + return "No diff data available." + + summary_lines = [] + + # Basic info + status = comparison_data.get('status', 'unknown') + ahead_by = comparison_data.get('ahead_by', 0) + behind_by = comparison_data.get('behind_by', 0) + total_commits = comparison_data.get('total_commits', 0) + + summary_lines.append(f"Comparison Status: {status}") + summary_lines.append(f"Commits ahead: {ahead_by}, behind: {behind_by}, total: {total_commits}") + summary_lines.append("") + + # Files changed + files = comparison_data.get('files', []) + if files: + summary_lines.append(f"Files changed: {len(files)}") + summary_lines.append("") + + # Group by status + added = [f for f in files if f.get('status') == 'added'] + removed = [f for f in files if f.get('status') == 'removed'] + modified = [f for f in files if f.get('status') == 'modified'] + renamed = [f for f in files if f.get('status') == 'renamed'] + + if added: + summary_lines.append(f"Added files ({len(added)}):") + for f in added[:10]: # Limit to first 10 + summary_lines.append(f" + {f.get('filename', 'unknown')}") + if len(added) > 10: + summary_lines.append(f" ... and {len(added) - 10} more") + summary_lines.append("") + + if removed: + summary_lines.append(f"Removed files ({len(removed)}):") + for f in removed[:10]: + summary_lines.append(f" - {f.get('filename', 'unknown')}") + if len(removed) > 10: + summary_lines.append(f" ... and {len(removed) - 10} more") + summary_lines.append("") + + if modified: + summary_lines.append(f"Modified files ({len(modified)}):") + for f in modified[:20]: # Show more modified files + changes = f.get('changes', 0) + additions = f.get('additions', 0) + deletions = f.get('deletions', 0) + summary_lines.append(f" ~ {f.get('filename', 'unknown')} (+{additions}, -{deletions}, {changes} total)") + if len(modified) > 20: + summary_lines.append(f" ... and {len(modified) - 20} more") + summary_lines.append("") + + if renamed: + summary_lines.append(f"Renamed files ({len(renamed)}):") + for f in renamed[:10]: + old_name = f.get('previous_filename', 'unknown') + new_name = f.get('filename', 'unknown') + summary_lines.append(f" → {old_name} → {new_name}") + if len(renamed) > 10: + summary_lines.append(f" ... and {len(renamed) - 10} more") + summary_lines.append("") + + # Filter for relevant files (modules, configs, etc.) + # + # TODO: double check this is the correct relevant list + relevant_files = [ + f for f in files + if any(keyword in f.get('filename', '').lower() for keyword in [ + 'module', 'x/', 'proto', 'upgrade', 'migration', 'changelog', 'release' + ]) + ] + + if relevant_files: + summary_lines.append("Potentially relevant files for documentation:") + for f in relevant_files[:15]: + summary_lines.append(f" • {f.get('filename', 'unknown')}") + if len(relevant_files) > 15: + summary_lines.append(f" ... and {len(relevant_files) - 15} more") + summary_lines.append("") + + # Commits summary + commits = comparison_data.get('commits', []) + if commits: + summary_lines.append(f"Recent commits ({min(5, len(commits))} of {len(commits)}):") + for commit in commits[:5]: + message = commit.get('commit', {}).get('message', '').split('\n')[0] + sha = commit.get('sha', '')[:7] + author = commit.get('commit', {}).get('author', {}).get('name', 'unknown') + summary_lines.append(f" [{sha}] {message} ({author})") + if len(commits) > 5: + summary_lines.append(f" ... and {len(commits) - 5} more commits") + summary_lines.append("") + + # Add comparison URL + html_url = comparison_data.get('html_url', '') + if html_url: + summary_lines.append(f"Full comparison: {html_url}") + + return "\n".join(summary_lines) + diff --git a/scripts/check_noble_version/parser.py b/scripts/check_noble_version/parser.py new file mode 100644 index 0000000..5969982 --- /dev/null +++ b/scripts/check_noble_version/parser.py @@ -0,0 +1,70 @@ +"""MDX file parsing utilities.""" + +import re +import sys +from pathlib import Path +from typing import Optional + +from check_noble_version.version import parse_version, compare_versions + + +def get_latest_version_from_upgrades(mdx_path: Path) -> Optional[str]: + """ + Parse the mainnet.mdx file and extract the latest version from the upgrades table. + + Args: + mdx_path: Path to the mainnet.mdx file + + Returns: + Latest version string (e.g., 'v11.0.0') or None if not found + """ + try: + with open(mdx_path, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + print(f"Error: File not found: {mdx_path}", file=sys.stderr) + return None + except Exception as e: + print(f"Error reading file {mdx_path}: {e}", file=sys.stderr) + return None + + # Pattern to match version tags in markdown links: [`vX.Y.Z`](url) or [`vX.Y.Z-suffix`](url) + # This matches patterns like [`v11.0.0`](https://github.com/...) or [`v11.0.0-rc1`](...) + # We capture versions with optional suffixes to detect and handle them + version_pattern = r'\[`(v\d+\.\d+\.\d+(?:-[a-zA-Z0-9]+)?)`\]' + + # Find all version matches + versions = re.findall(version_pattern, content) + + if not versions: + print("Warning: No versions found in the upgrades table", file=sys.stderr) + return None + + # Check for versions with suffixes and report them + versions_with_suffixes = [v for v in versions if '-' in v] + if versions_with_suffixes: + print("\n⚠ Warning: Found versions with suffixes in the upgrades table:", file=sys.stderr) + for v in versions_with_suffixes: + print(f" - {v}", file=sys.stderr) + print("\nThis tool cannot handle version suffixes (e.g., -rc1, -beta, -alpha).", file=sys.stderr) + print("Please update the documentation to use only release versions.", file=sys.stderr) + print("\nError details:", file=sys.stderr) + try: + # Try to parse one to trigger the error message + parse_version(versions_with_suffixes[0]) + except ValueError as e: + print(f"{e}", file=sys.stderr) + return None + + # Find the latest version by comparing all found versions + try: + latest_version = versions[0] + for version in versions[1:]: + if compare_versions(version, latest_version) > 0: + latest_version = version + except ValueError as e: + print(f"\nError: Failed to compare versions: {e}", file=sys.stderr) + return None + + return latest_version + diff --git a/scripts/check_noble_version/test_github.py b/scripts/check_noble_version/test_github.py new file mode 100644 index 0000000..a80f8db --- /dev/null +++ b/scripts/check_noble_version/test_github.py @@ -0,0 +1,77 @@ +"""Tests for github.py""" + +from check_noble_version.github import get_diff_between_tags, format_diff_summary + + +class TestGetDiffBetweenTags: + """Tests for get_diff_between_tags function""" + + def test_get_diff_between_tags_invalid_repo(self): + """Test that function returns None for invalid repository""" + result = get_diff_between_tags("invalid/repo", "v1.0.0", "v2.0.0") + assert result is None + + def test_get_diff_between_tags_invalid_tags(self): + """Test that function returns None for invalid tags""" + result = get_diff_between_tags("noble-assets/noble", "v999.999.999", "v999.999.998") + assert result is None + + +class TestFormatDiffSummary: + """Tests for format_diff_summary function""" + + def test_format_diff_summary_empty(self): + """Test formatting empty diff data""" + result = format_diff_summary({}) + assert "No diff data available" in result + + def test_format_diff_summary_basic(self): + """Test formatting basic diff data""" + comparison_data = { + "status": "ahead", + "ahead_by": 5, + "behind_by": 0, + "total_commits": 5, + "files": [ + {"filename": "x/module/file.go", "status": "modified", "additions": 10, "deletions": 5, "changes": 15}, + {"filename": "test.go", "status": "added", "additions": 20, "deletions": 0, "changes": 20}, + ], + "commits": [ + { + "sha": "abc1234", + "commit": { + "message": "Add new feature", + "author": {"name": "Test Author"} + } + } + ], + "html_url": "https://github.com/noble-assets/noble/compare/v1...v2" + } + result = format_diff_summary(comparison_data) + assert "Comparison Status: ahead" in result + assert "Commits ahead: 5" in result + assert "Files changed: 2" in result + assert "x/module/file.go" in result + assert "Add new feature" in result + assert "https://github.com/noble-assets/noble/compare/v1...v2" in result + + def test_format_diff_summary_relevant_files(self): + """Test that relevant files are highlighted""" + comparison_data = { + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "total_commits": 1, + "files": [ + {"filename": "x/module/file.go", "status": "modified", "additions": 10, "deletions": 5, "changes": 15}, + {"filename": "proto/upgrade.proto", "status": "modified", "additions": 5, "deletions": 2, "changes": 7}, + {"filename": "unrelated.go", "status": "modified", "additions": 1, "deletions": 1, "changes": 2}, + ], + "commits": [], + "html_url": "" + } + result = format_diff_summary(comparison_data) + assert "Potentially relevant files for documentation:" in result + assert "x/module/file.go" in result + assert "proto/upgrade.proto" in result + diff --git a/scripts/check_noble_version/test_parser.py b/scripts/check_noble_version/test_parser.py new file mode 100644 index 0000000..ba5a19a --- /dev/null +++ b/scripts/check_noble_version/test_parser.py @@ -0,0 +1,91 @@ +"""Tests for parser.py""" + +import tempfile +from pathlib import Path + +from check_noble_version.parser import get_latest_version_from_upgrades + + +class TestGetLatestVersionFromUpgrades: + """Tests for get_latest_version_from_upgrades function""" + + def test_get_latest_version_simple(self): + """Test extracting latest version from simple MDX content""" + content = """ +| Tag | Name | +|-----|------| +| [`v1.0.0`](url) | Version 1 | +| [`v2.0.0`](url) | Version 2 | +| [`v3.0.0`](url) | Version 3 | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result == "v3.0.0" + + path.unlink() + + def test_get_latest_version_unordered(self): + """Test extracting latest version when versions are not in order""" + content = """ +| Tag | Name | +|-----|------| +| [`v5.0.0`](url) | Version 5 | +| [`v2.0.0`](url) | Version 2 | +| [`v10.0.0`](url) | Version 10 | +| [`v3.0.0`](url) | Version 3 | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result == "v10.0.0" + + path.unlink() + + def test_get_latest_version_with_suffix_returns_none(self): + """Test that versions with suffixes cause function to return None""" + content = """ +| Tag | Name | +|-----|------| +| [`v1.0.0`](url) | Version 1 | +| [`v2.0.0-rc1`](url) | RC Version | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result is None + + path.unlink() + + def test_get_latest_version_no_versions_returns_none(self): + """Test that function returns None when no versions are found""" + content = """ +| Tag | Name | +|-----|------| +| Some content without versions | +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: + f.write(content) + f.flush() + path = Path(f.name) + + result = get_latest_version_from_upgrades(path) + assert result is None + + path.unlink() + + def test_get_latest_version_file_not_found_returns_none(self): + """Test that function returns None when file doesn't exist""" + non_existent_path = Path("/tmp/non_existent_file_12345.mdx") + result = get_latest_version_from_upgrades(non_existent_path) + assert result is None + diff --git a/scripts/check_noble_version/test_tracker.py b/scripts/check_noble_version/test_tracker.py new file mode 100644 index 0000000..b256a2e --- /dev/null +++ b/scripts/check_noble_version/test_tracker.py @@ -0,0 +1,74 @@ +"""Tests for tracker.py""" + +import json +import tempfile +from pathlib import Path + +from check_noble_version.tracker import load_tracker, save_tracker + + +class TestLoadTracker: + """Tests for load_tracker function""" + + def test_load_tracker_file_not_exists(self): + """Test loading tracker when file doesn't exist""" + non_existent_path = Path("/tmp/non_existent_tracker_12345.json") + result = load_tracker(non_existent_path) + assert result == { + "last_tracked_version": None, + "last_checked": None + } + + def test_load_tracker_valid_file(self): + """Test loading tracker from valid JSON file""" + tracker_data = { + "last_tracked_version": "v11.0.0", + "last_checked": "2024-01-01T00:00:00Z" + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(tracker_data, f) + f.flush() + path = Path(f.name) + + result = load_tracker(path) + assert result == tracker_data + + path.unlink() + + def test_load_tracker_invalid_json(self): + """Test loading tracker with invalid JSON returns default""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + f.write("invalid json content {") + f.flush() + path = Path(f.name) + + result = load_tracker(path) + assert result == { + "last_tracked_version": None, + "last_checked": None + } + + path.unlink() + + +class TestSaveTracker: + """Tests for save_tracker function""" + + def test_save_tracker_creates_file(self): + """Test that save_tracker creates a file with correct content""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + path = Path(f.name) + path.unlink() # Delete the file so we can test creation + + save_tracker("v11.0.0", path) + + assert path.exists() + with open(path, 'r') as f: + data = json.load(f) + assert data["last_tracked_version"] == "v11.0.0" + assert "last_checked" in data + assert data["last_checked"] is not None + + path.unlink() + diff --git a/scripts/check_noble_version/test_version.py b/scripts/check_noble_version/test_version.py new file mode 100644 index 0000000..f2ac7ab --- /dev/null +++ b/scripts/check_noble_version/test_version.py @@ -0,0 +1,83 @@ +"""Tests for version.py""" + +import pytest + +from check_noble_version.version import parse_version, compare_versions + + +class TestParseVersion: + """Tests for parse_version function""" + + def test_parse_version_with_v_prefix(self): + """Test parsing version with 'v' prefix""" + assert parse_version("v11.0.0") == (11, 0, 0) + assert parse_version("v1.2.3") == (1, 2, 3) + assert parse_version("v10.5.20") == (10, 5, 20) + + def test_parse_version_without_v_prefix(self): + """Test parsing version without 'v' prefix""" + assert parse_version("11.0.0") == (11, 0, 0) + assert parse_version("1.2.3") == (1, 2, 3) + + def test_parse_version_raises_on_suffix(self): + """Test that parse_version raises ValueError for versions with suffixes""" + with pytest.raises(ValueError, match="Version with suffix detected"): + parse_version("v11.0.0-rc1") + + with pytest.raises(ValueError, match="Version with suffix detected"): + parse_version("v11.0.0-beta") + + with pytest.raises(ValueError, match="Version with suffix detected"): + parse_version("v11.0.0-alpha") + + def test_parse_version_raises_on_invalid_format(self): + """Test that parse_version raises ValueError for invalid formats""" + with pytest.raises(ValueError, match="Invalid version format"): + parse_version("11.0") # Missing patch version + + with pytest.raises(ValueError, match="Invalid version format"): + parse_version("11") # Only major version + + with pytest.raises(ValueError, match="Invalid version format"): + parse_version("11.0.0.1") # Too many parts + + def test_parse_version_raises_on_non_integer(self): + """Test that parse_version raises ValueError for non-integer components""" + with pytest.raises(ValueError, match="All version components must be integers"): + parse_version("v11.0.a") + + with pytest.raises(ValueError, match="All version components must be integers"): + parse_version("v11.x.0") + + +class TestCompareVersions: + """Tests for compare_versions function""" + + def test_compare_versions_equal(self): + """Test comparing equal versions""" + assert compare_versions("v11.0.0", "v11.0.0") == 0 + assert compare_versions("v1.2.3", "v1.2.3") == 0 + assert compare_versions("11.0.0", "v11.0.0") == 0 + + def test_compare_versions_less_than(self): + """Test comparing when first version is less than second""" + assert compare_versions("v10.0.0", "v11.0.0") == -1 + assert compare_versions("v11.0.0", "v11.1.0") == -1 + assert compare_versions("v11.0.0", "v11.0.1") == -1 + assert compare_versions("v1.0.0", "v2.0.0") == -1 + + def test_compare_versions_greater_than(self): + """Test comparing when first version is greater than second""" + assert compare_versions("v11.0.0", "v10.0.0") == 1 + assert compare_versions("v11.1.0", "v11.0.0") == 1 + assert compare_versions("v11.0.1", "v11.0.0") == 1 + assert compare_versions("v2.0.0", "v1.0.0") == 1 + + def test_compare_versions_with_suffix_raises(self): + """Test that compare_versions raises ValueError when versions have suffixes""" + with pytest.raises(ValueError, match="Error comparing versions"): + compare_versions("v11.0.0-rc1", "v11.0.0") + + with pytest.raises(ValueError, match="Error comparing versions"): + compare_versions("v11.0.0", "v11.0.0-rc1") + diff --git a/scripts/check_noble_version/tracker.py b/scripts/check_noble_version/tracker.py new file mode 100644 index 0000000..c49f67a --- /dev/null +++ b/scripts/check_noble_version/tracker.py @@ -0,0 +1,69 @@ +"""Tracker file operations.""" + +import json +import sys +from pathlib import Path +from typing import Optional +from datetime import datetime, timezone + +from check_noble_version.config import TRACKER_JSON_PATH + + +def load_tracker(tracker_path: Optional[Path] = None) -> dict: + """ + Load the version tracker JSON file. + + Args: + tracker_path: Optional path to tracker file. Defaults to TRACKER_JSON_PATH. + + Returns: + Dictionary with tracker data, or default structure if file doesn't exist + """ + if tracker_path is None: + tracker_path = TRACKER_JSON_PATH + + if not tracker_path.exists(): + return { + "last_tracked_version": None, + "last_checked": None + } + + try: + with open(tracker_path, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in tracker file: {e}", file=sys.stderr) + return { + "last_tracked_version": None, + "last_checked": None + } + except Exception as e: + print(f"Error reading tracker file: {e}", file=sys.stderr) + return { + "last_tracked_version": None, + "last_checked": None + } + + +def save_tracker(version: str, tracker_path: Optional[Path] = None): + """ + Save the current version to the tracker JSON file. + + Args: + version: Version string to save + tracker_path: Optional path to tracker file. Defaults to TRACKER_JSON_PATH. + """ + if tracker_path is None: + tracker_path = TRACKER_JSON_PATH + + tracker_data = { + "last_tracked_version": version, + "last_checked": datetime.now(timezone.utc).isoformat() + } + + try: + with open(tracker_path, 'w', encoding='utf-8') as f: + json.dump(tracker_data, f, indent=2) + except Exception as e: + print(f"Error writing tracker file: {e}", file=sys.stderr) + diff --git a/scripts/check_noble_version/version.py b/scripts/check_noble_version/version.py new file mode 100644 index 0000000..fac574c --- /dev/null +++ b/scripts/check_noble_version/version.py @@ -0,0 +1,70 @@ +"""Version parsing and comparison utilities.""" + +from typing import Tuple + + +def parse_version(version_str: str) -> Tuple[int, int, int]: + """ + Parse a version string (e.g., 'v11.0.0') into a tuple of (major, minor, patch). + + Args: + version_str: Version string in format 'vX.Y.Z' or 'X.Y.Z' + + Returns: + Tuple of (major, minor, patch) integers + + Raises: + ValueError: If version format is invalid or contains suffixes (e.g., -rc1) + """ + # Check for version suffixes (e.g., -rc1, -beta, -alpha) + if '-' in version_str: + raise ValueError( + f"Version with suffix detected: '{version_str}'\n" + f"This tool currently does not support version suffixes (e.g., -rc1, -beta, -alpha).\n" + f"Please use only release versions in the format 'vX.Y.Z' (e.g., 'v11.0.0')." + ) + + # Remove 'v' prefix if present + version_str = version_str.lstrip('v') + parts = version_str.split('.') + if len(parts) != 3: + raise ValueError( + f"Invalid version format: '{version_str}'\n" + f"Expected format: 'vX.Y.Z' or 'X.Y.Z' where X, Y, Z are integers." + ) + + try: + return (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError as e: + raise ValueError( + f"Invalid version format: '{version_str}'\n" + f"All version components must be integers. Error: {e}" + ) + + +def compare_versions(version1: str, version2: str) -> int: + """ + Compare two version strings. + + Returns: + -1 if version1 < version2 + 0 if version1 == version2 + 1 if version1 > version2 + + Raises: + ValueError: If either version format is invalid or contains suffixes + """ + try: + v1 = parse_version(version1) + v2 = parse_version(version2) + except ValueError as e: + # Re-raise with context about which version failed + raise ValueError(f"Error comparing versions '{version1}' and '{version2}':\n{e}") + + if v1 < v2: + return -1 + elif v1 > v2: + return 1 + else: + return 0 + diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e66f6c1..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Tests for Noble Documentation Version Tracker - diff --git a/tests/test_check_noble_version.py b/tests/test_check_noble_version.py deleted file mode 100644 index 4463fda..0000000 --- a/tests/test_check_noble_version.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Unit tests for check_noble_version.py""" - -import json -import pytest -import tempfile -from pathlib import Path - -import sys -sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) - -from check_noble_version import ( - parse_version, - compare_versions, - get_latest_version_from_upgrades, - load_tracker, - save_tracker, - get_diff_between_tags, - format_diff_summary, -) - - -class TestParseVersion: - """Tests for parse_version function""" - - def test_parse_version_with_v_prefix(self): - """Test parsing version with 'v' prefix""" - assert parse_version("v11.0.0") == (11, 0, 0) - assert parse_version("v1.2.3") == (1, 2, 3) - assert parse_version("v10.5.20") == (10, 5, 20) - - def test_parse_version_without_v_prefix(self): - """Test parsing version without 'v' prefix""" - assert parse_version("11.0.0") == (11, 0, 0) - assert parse_version("1.2.3") == (1, 2, 3) - - def test_parse_version_raises_on_suffix(self): - """Test that parse_version raises ValueError for versions with suffixes""" - with pytest.raises(ValueError, match="Version with suffix detected"): - parse_version("v11.0.0-rc1") - - with pytest.raises(ValueError, match="Version with suffix detected"): - parse_version("v11.0.0-beta") - - with pytest.raises(ValueError, match="Version with suffix detected"): - parse_version("v11.0.0-alpha") - - def test_parse_version_raises_on_invalid_format(self): - """Test that parse_version raises ValueError for invalid formats""" - with pytest.raises(ValueError, match="Invalid version format"): - parse_version("11.0") # Missing patch version - - with pytest.raises(ValueError, match="Invalid version format"): - parse_version("11") # Only major version - - with pytest.raises(ValueError, match="Invalid version format"): - parse_version("11.0.0.1") # Too many parts - - def test_parse_version_raises_on_non_integer(self): - """Test that parse_version raises ValueError for non-integer components""" - with pytest.raises(ValueError, match="All version components must be integers"): - parse_version("v11.0.a") - - with pytest.raises(ValueError, match="All version components must be integers"): - parse_version("v11.x.0") - - -class TestCompareVersions: - """Tests for compare_versions function""" - - def test_compare_versions_equal(self): - """Test comparing equal versions""" - assert compare_versions("v11.0.0", "v11.0.0") == 0 - assert compare_versions("v1.2.3", "v1.2.3") == 0 - assert compare_versions("11.0.0", "v11.0.0") == 0 - - def test_compare_versions_less_than(self): - """Test comparing when first version is less than second""" - assert compare_versions("v10.0.0", "v11.0.0") == -1 - assert compare_versions("v11.0.0", "v11.1.0") == -1 - assert compare_versions("v11.0.0", "v11.0.1") == -1 - assert compare_versions("v1.0.0", "v2.0.0") == -1 - - def test_compare_versions_greater_than(self): - """Test comparing when first version is greater than second""" - assert compare_versions("v11.0.0", "v10.0.0") == 1 - assert compare_versions("v11.1.0", "v11.0.0") == 1 - assert compare_versions("v11.0.1", "v11.0.0") == 1 - assert compare_versions("v2.0.0", "v1.0.0") == 1 - - def test_compare_versions_with_suffix_raises(self): - """Test that compare_versions raises ValueError when versions have suffixes""" - with pytest.raises(ValueError, match="Error comparing versions"): - compare_versions("v11.0.0-rc1", "v11.0.0") - - with pytest.raises(ValueError, match="Error comparing versions"): - compare_versions("v11.0.0", "v11.0.0-rc1") - - -class TestGetLatestVersionFromUpgrades: - """Tests for get_latest_version_from_upgrades function""" - - def test_get_latest_version_simple(self): - """Test extracting latest version from simple MDX content""" - content = """ -| Tag | Name | -|-----|------| -| [`v1.0.0`](url) | Version 1 | -| [`v2.0.0`](url) | Version 2 | -| [`v3.0.0`](url) | Version 3 | -""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: - f.write(content) - f.flush() - path = Path(f.name) - - result = get_latest_version_from_upgrades(path) - assert result == "v3.0.0" - - path.unlink() - - def test_get_latest_version_unordered(self): - """Test extracting latest version when versions are not in order""" - content = """ -| Tag | Name | -|-----|------| -| [`v5.0.0`](url) | Version 5 | -| [`v2.0.0`](url) | Version 2 | -| [`v10.0.0`](url) | Version 10 | -| [`v3.0.0`](url) | Version 3 | -""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: - f.write(content) - f.flush() - path = Path(f.name) - - result = get_latest_version_from_upgrades(path) - assert result == "v10.0.0" - - path.unlink() - - def test_get_latest_version_with_suffix_returns_none(self): - """Test that versions with suffixes cause function to return None""" - content = """ -| Tag | Name | -|-----|------| -| [`v1.0.0`](url) | Version 1 | -| [`v2.0.0-rc1`](url) | RC Version | -""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: - f.write(content) - f.flush() - path = Path(f.name) - - result = get_latest_version_from_upgrades(path) - assert result is None - - path.unlink() - - def test_get_latest_version_no_versions_returns_none(self): - """Test that function returns None when no versions are found""" - content = """ -| Tag | Name | -|-----|------| -| Some content without versions | -""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.mdx', delete=False) as f: - f.write(content) - f.flush() - path = Path(f.name) - - result = get_latest_version_from_upgrades(path) - assert result is None - - path.unlink() - - def test_get_latest_version_file_not_found_returns_none(self): - """Test that function returns None when file doesn't exist""" - non_existent_path = Path("/tmp/non_existent_file_12345.mdx") - result = get_latest_version_from_upgrades(non_existent_path) - assert result is None - - -class TestLoadTracker: - """Tests for load_tracker function""" - - def test_load_tracker_file_not_exists(self): - """Test loading tracker when file doesn't exist""" - non_existent_path = Path("/tmp/non_existent_tracker_12345.json") - result = load_tracker(non_existent_path) - assert result == { - "last_tracked_version": None, - "last_checked": None - } - - def test_load_tracker_valid_file(self): - """Test loading tracker from valid JSON file""" - tracker_data = { - "last_tracked_version": "v11.0.0", - "last_checked": "2024-01-01T00:00:00Z" - } - - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - json.dump(tracker_data, f) - f.flush() - path = Path(f.name) - - result = load_tracker(path) - assert result == tracker_data - - path.unlink() - - def test_load_tracker_invalid_json(self): - """Test loading tracker with invalid JSON returns default""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - f.write("invalid json content {") - f.flush() - path = Path(f.name) - - result = load_tracker(path) - assert result == { - "last_tracked_version": None, - "last_checked": None - } - - path.unlink() - - -class TestSaveTracker: - """Tests for save_tracker function""" - - def test_save_tracker_creates_file(self): - """Test that save_tracker creates a file with correct content""" - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - path = Path(f.name) - path.unlink() # Delete the file so we can test creation - - save_tracker("v11.0.0", path) - - assert path.exists() - with open(path, 'r') as f: - data = json.load(f) - assert data["last_tracked_version"] == "v11.0.0" - assert "last_checked" in data - assert data["last_checked"] is not None - - path.unlink() - - -class TestGetDiffBetweenTags: - """Tests for get_diff_between_tags function""" - - def test_get_diff_between_tags_invalid_repo(self): - """Test that function returns None for invalid repository""" - result = get_diff_between_tags("invalid/repo", "v1.0.0", "v2.0.0") - assert result is None - - def test_get_diff_between_tags_invalid_tags(self): - """Test that function returns None for invalid tags""" - result = get_diff_between_tags("noble-assets/noble", "v999.999.999", "v999.999.998") - assert result is None - - -class TestFormatDiffSummary: - """Tests for format_diff_summary function""" - - def test_format_diff_summary_empty(self): - """Test formatting empty diff data""" - result = format_diff_summary({}) - assert "No diff data available" in result - - def test_format_diff_summary_basic(self): - """Test formatting basic diff data""" - comparison_data = { - "status": "ahead", - "ahead_by": 5, - "behind_by": 0, - "total_commits": 5, - "files": [ - {"filename": "x/module/file.go", "status": "modified", "additions": 10, "deletions": 5, "changes": 15}, - {"filename": "test.go", "status": "added", "additions": 20, "deletions": 0, "changes": 20}, - ], - "commits": [ - { - "sha": "abc1234", - "commit": { - "message": "Add new feature", - "author": {"name": "Test Author"} - } - } - ], - "html_url": "https://github.com/noble-assets/noble/compare/v1...v2" - } - result = format_diff_summary(comparison_data) - assert "Comparison Status: ahead" in result - assert "Commits ahead: 5" in result - assert "Files changed: 2" in result - assert "x/module/file.go" in result - assert "Add new feature" in result - assert "https://github.com/noble-assets/noble/compare/v1...v2" in result - - def test_format_diff_summary_relevant_files(self): - """Test that relevant files are highlighted""" - comparison_data = { - "status": "ahead", - "ahead_by": 1, - "behind_by": 0, - "total_commits": 1, - "files": [ - {"filename": "x/module/file.go", "status": "modified", "additions": 10, "deletions": 5, "changes": 15}, - {"filename": "proto/upgrade.proto", "status": "modified", "additions": 5, "deletions": 2, "changes": 7}, - {"filename": "unrelated.go", "status": "modified", "additions": 1, "deletions": 1, "changes": 2}, - ], - "commits": [], - "html_url": "" - } - result = format_diff_summary(comparison_data) - assert "Potentially relevant files for documentation:" in result - assert "x/module/file.go" in result - assert "proto/upgrade.proto" in result - From 73ca44c47bc827a73ebc5e332017b76a7e935010 Mon Sep 17 00:00:00 2001 From: Malte Herrmann Date: Fri, 14 Nov 2025 17:07:34 +0100 Subject: [PATCH 7/7] add logic to check module versions from go.mod in Noble --- scripts/check_noble_version/__init__.py | 10 + scripts/check_noble_version/cli.py | 56 +++ scripts/check_noble_version/modules.py | 378 ++++++++++++++++++++ scripts/check_noble_version/test_modules.py | 241 +++++++++++++ 4 files changed, 685 insertions(+) create mode 100644 scripts/check_noble_version/modules.py create mode 100644 scripts/check_noble_version/test_modules.py diff --git a/scripts/check_noble_version/__init__.py b/scripts/check_noble_version/__init__.py index 82180bc..e5f9b0f 100644 --- a/scripts/check_noble_version/__init__.py +++ b/scripts/check_noble_version/__init__.py @@ -9,6 +9,12 @@ from check_noble_version.parser import get_latest_version_from_upgrades from check_noble_version.tracker import load_tracker, save_tracker from check_noble_version.github import get_diff_between_tags, format_diff_summary +from check_noble_version.modules import ( + get_relevant_module_paths, + get_module_versions_for_tag, + get_module_diffs, + MODULE_MAPPINGS, +) from check_noble_version.config import ( REPO_ROOT, MAINNET_MDX_PATH, @@ -24,6 +30,10 @@ "save_tracker", "get_diff_between_tags", "format_diff_summary", + "get_relevant_module_paths", + "get_module_versions_for_tag", + "get_module_diffs", + "MODULE_MAPPINGS", "REPO_ROOT", "MAINNET_MDX_PATH", "TRACKER_JSON_PATH", diff --git a/scripts/check_noble_version/cli.py b/scripts/check_noble_version/cli.py index 9f8867c..df89953 100644 --- a/scripts/check_noble_version/cli.py +++ b/scripts/check_noble_version/cli.py @@ -10,6 +10,10 @@ from check_noble_version.tracker import load_tracker, save_tracker from check_noble_version.version import compare_versions from check_noble_version.github import get_diff_between_tags, format_diff_summary +from check_noble_version.modules import ( + get_module_versions_for_tag, + get_module_diffs, +) def main(): @@ -61,6 +65,58 @@ def main(): print("=" * 50) print(format_diff_summary(diff_data)) print("=" * 50) + + # Get module-specific information + print("\n" + "=" * 50) + print("MODULE VERSIONS & DIFFS") + print("=" * 50) + + # Get module versions for both tags + print(f"\nFetching module versions for {last_tracked}...") + base_modules = get_module_versions_for_tag(GITHUB_REPO, last_tracked) + print(f"Fetching module versions for {latest_version}...") + head_modules = get_module_versions_for_tag(GITHUB_REPO, latest_version) + + if base_modules and head_modules: + print("\nModule Version Changes:") + all_module_paths = set(base_modules.keys()) | set(head_modules.keys()) + for module_path in sorted(all_module_paths): + base_version = base_modules.get(module_path, "N/A") + head_version = head_modules.get(module_path, "N/A") + if base_version != head_version: + print(f" {module_path}: {base_version} → {head_version}") + elif base_version != "N/A": + print(f" {module_path}: {base_version} (unchanged)") + + # Get module-specific diffs + print(f"\nFetching module-specific diffs...") + module_diffs = get_module_diffs(GITHUB_REPO, last_tracked, latest_version) + + if module_diffs: + print("\nModule-Specific Changes:") + for module_path, diff_info in sorted(module_diffs.items()): + file_count = len(diff_info['files']) + additions = diff_info['total_additions'] + deletions = diff_info['total_deletions'] + changes = diff_info['total_changes'] + print(f"\n {module_path}:") + print(f" Files changed: {file_count}") + print(f" Changes: +{additions}, -{deletions} ({changes} total)") + if file_count <= 10: + for file_info in diff_info['files']: + status = file_info.get('status', 'unknown') + filename = file_info.get('filename', 'unknown') + print(f" [{status}] {filename}") + else: + for file_info in diff_info['files'][:5]: + status = file_info.get('status', 'unknown') + filename = file_info.get('filename', 'unknown') + print(f" [{status}] {filename}") + print(f" ... and {file_count - 5} more files") + else: + print("\n No changes detected in tracked modules.") + + print("=" * 50) else: print(f"\n ⚠ Could not fetch diff from GitHub API.") print(f" You can view the comparison manually at:") diff --git a/scripts/check_noble_version/modules.py b/scripts/check_noble_version/modules.py new file mode 100644 index 0000000..4f34bb7 --- /dev/null +++ b/scripts/check_noble_version/modules.py @@ -0,0 +1,378 @@ +"""Module configuration and Go module tracking.""" + +from typing import Dict, List, Optional +import re +import sys + +try: + import requests +except ImportError: + print("Error: requests library is required. Install it with: pip install requests", file=sys.stderr) + sys.exit(1) + +from check_noble_version.config import GITHUB_REPO + + +# Mapping of module names (as they appear in docs) to their Go module paths +# This should match the modules documented in technical_reference/modules +MODULE_MAPPINGS: Dict[str, List[str]] = { + "dollar": [ + "dollar.noble.xyz", + "github.com/noble-assets/dollar", + ], + "orbiter": [ + "github.com/noble-assets/orbiter", + ], + "forwarding": [ + "github.com/noble-assets/forwarding", + ], + "swap": [ + "github.com/noble-assets/swap", + ], + "cctp": [ + "github.com/circlefin/noble-cctp", + ], + "aura": [ + "github.com/noble-assets/aura", + ], + "authority": [ + "github.com/noble-assets/authority", + ], + "blockibc": [ + "github.com/noble-assets/blockibc", + ], + "fiattokenfactory": [ + "github.com/noble-assets/fiattokenfactory", + ], + "florin": [ + "github.com/noble-assets/florin", + ], + "globalfee": [ + "github.com/noble-assets/globalfee", + ], + "halo": [ + "github.com/noble-assets/halo", + ], + "wormhole": [ + "github.com/noble-assets/wormhole", + ], +} + + +def get_relevant_module_paths() -> List[str]: + """ + Get a flat list of all relevant Go module paths. + + Returns: + List of Go module paths that should be tracked + """ + paths = [] + for module_paths in MODULE_MAPPINGS.values(): + paths.extend(module_paths) + return sorted(set(paths)) # Remove duplicates and sort + + +def fetch_go_mod(repo: str, tag: str) -> Optional[str]: + """ + Fetch the go.mod file content from GitHub for a specific tag. + + Args: + repo: Repository in format 'owner/repo' + tag: Git tag (e.g., 'v11.0.0') + + Returns: + Content of go.mod file as string, or None on error + """ + url = f"https://raw.githubusercontent.com/{repo}/{tag}/go.mod" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + return response.text + except requests.exceptions.RequestException as e: + print(f"Error fetching go.mod from GitHub: {e}", file=sys.stderr) + return None + + +def parse_go_mod(go_mod_content: str) -> Dict[str, str]: + """ + Parse go.mod content and extract module dependencies. + + Args: + go_mod_content: Content of go.mod file + + Returns: + Dictionary mapping module paths to their versions + """ + modules = {} + + # Pattern to match require statements: require module/path v1.2.3 + # Also handles replace directives and indirect dependencies + require_pattern = r'^\s*(?:require\s+)?([^\s]+)\s+([^\s]+)(?:\s+//.*)?$' + + lines = go_mod_content.split('\n') + in_require_block = False + + for line in lines: + line = line.strip() + + # Check if we're entering a require block + if line == 'require (': + in_require_block = True + continue + elif line == ')': + in_require_block = False + continue + elif line.startswith('require '): + in_require_block = False + # Single line require + match = re.match(require_pattern, line[8:].strip()) + if match: + module_path, version = match.groups() + modules[module_path] = version + continue + + # Handle lines within require block or standalone requires + if in_require_block or line.startswith(('require ', '\t')): + match = re.match(require_pattern, line) + if match: + module_path, version = match.groups() + modules[module_path] = version + + return modules + + +def parse_replace_directives(go_mod_content: str) -> Dict[str, str]: + """ + Parse replace directives from go.mod content. + + Args: + go_mod_content: Content of go.mod file + + Returns: + Dictionary mapping original module paths to their replacement paths + Format: {original_path: replacement_path} + """ + replaces = {} + + # Pattern to match replace directives: + # replace old/path => new/path v1.2.3 + # replace old/path => ../local/path + # replace old/path v1.2.3 => new/path v1.2.3 + replace_pattern = r'^\s*replace\s+([^\s]+)(?:\s+[^\s]+)?\s+=>\s+([^\s]+)(?:\s+[^\s]+)?(?:\s+//.*)?$' + + lines = go_mod_content.split('\n') + in_replace_block = False + + for line in lines: + stripped = line.strip() + + # Check if we're entering a replace block + if stripped == 'replace (': + in_replace_block = True + continue + elif stripped == ')': + in_replace_block = False + continue + elif stripped.startswith('replace '): + in_replace_block = False + # Single line replace + match = re.match(replace_pattern, line) + if match: + original_path, replacement_path = match.groups() + replaces[original_path] = replacement_path + continue + + # Handle lines within replace block (indented lines without 'replace' keyword) + if in_replace_block and stripped: + # Pattern for lines within replace block (no 'replace' keyword, just indented) + # Format: old/path => new/path v1.2.3 + block_pattern = r'^\s+([^\s]+)(?:\s+[^\s]+)?\s+=>\s+([^\s]+)(?:\s+[^\s]+)?(?:\s+//.*)?$' + match = re.match(block_pattern, line) + if match: + original_path, replacement_path = match.groups() + replaces[original_path] = replacement_path + + return replaces + + +def apply_replace_directives(modules: Dict[str, str], replaces: Dict[str, str]) -> Dict[str, str]: + """ + Apply replace directives to module dictionary. + + If a module has a replace directive, use the replacement path instead. + + Args: + modules: Dictionary mapping module paths to versions + replaces: Dictionary mapping original paths to replacement paths + + Returns: + Dictionary with replaced module paths + """ + result = {} + + for module_path, version in modules.items(): + # Check if this module has a replace directive + if module_path in replaces: + # Use the replacement path instead + replacement_path = replaces[module_path] + result[replacement_path] = version + else: + # Keep original + result[module_path] = version + + return result + + +def get_relevant_modules_from_go_mod(go_mod_content: str) -> Dict[str, str]: + """ + Extract only the relevant modules (from MODULE_MAPPINGS) from go.mod content. + Applies replace directives before filtering and dynamically includes replacement paths. + + Args: + go_mod_content: Content of go.mod file + + Returns: + Dictionary mapping relevant module paths to their versions (after applying replaces) + Includes both original and replacement paths if replacements exist + """ + # Parse modules and replace directives + all_modules = parse_go_mod(go_mod_content) + replaces = parse_replace_directives(go_mod_content) + + # Get base relevant paths (original module paths from MODULE_MAPPINGS) + relevant_paths = get_relevant_module_paths() + + # Build a set of all relevant paths including replacements + # If a relevant module has a replace directive, include the replacement path too + all_relevant_paths = set(relevant_paths) + for original_path, replacement_path in replaces.items(): + if original_path in relevant_paths: + # This relevant module has a replacement - include the replacement path + all_relevant_paths.add(replacement_path) + + # Apply replace directives to get the actual modules being used + modules_with_replaces = apply_replace_directives(all_modules, replaces) + + # Filter to relevant modules (checking both original and replacement paths) + relevant_modules = {} + + for module_path, version in modules_with_replaces.items(): + # Check if this module path matches any of our relevant paths (original or replacement) + for relevant_path in all_relevant_paths: + if module_path == relevant_path or module_path.startswith(relevant_path + '/'): + relevant_modules[module_path] = version + break + + return relevant_modules + + +def get_module_versions_for_tag(repo: str, tag: str) -> Optional[Dict[str, str]]: + """ + Get relevant module versions for a specific tag. + + Args: + repo: Repository in format 'owner/repo' + tag: Git tag (e.g., 'v11.0.0') + + Returns: + Dictionary mapping module paths to versions, or None on error + """ + go_mod_content = fetch_go_mod(repo, tag) + if not go_mod_content: + return None + + return get_relevant_modules_from_go_mod(go_mod_content) + + +def _build_module_to_dir_mapping(repo: str, tag: str) -> Dict[str, str]: + """ + Build a mapping from Go module paths to directory paths, including replacement paths. + + Args: + repo: Repository in format 'owner/repo' + tag: Git tag to fetch go.mod from + + Returns: + Dictionary mapping Go module paths (original and replacement) to directory paths + """ + # Base mapping: original module paths to directories + base_mapping = { + "dollar.noble.xyz": "x/dollar", + "github.com/noble-assets/dollar": "x/dollar", + "github.com/noble-assets/orbiter": "x/orbiter", + "github.com/noble-assets/forwarding": "x/forwarding", + "github.com/noble-assets/swap": "x/swap", + "github.com/circlefin/noble-cctp": "x/cctp", + "github.com/noble-assets/aura": "x/aura", + "github.com/noble-assets/authority": "x/authority", + "github.com/noble-assets/blockibc": "x/blockibc", + "github.com/noble-assets/fiattokenfactory": "x/fiattokenfactory", + "github.com/noble-assets/florin": "x/florin", + "github.com/noble-assets/globalfee": "x/globalfee", + "github.com/noble-assets/halo": "x/halo", + "github.com/noble-assets/wormhole": "x/wormhole", + } + + # Fetch go.mod to get replace directives + go_mod_content = fetch_go_mod(repo, tag) + if go_mod_content: + replaces = parse_replace_directives(go_mod_content) + # Add replacement paths that map to the same directories as their originals + for original_path, replacement_path in replaces.items(): + if original_path in base_mapping: + # Map the replacement path to the same directory as the original + base_mapping[replacement_path] = base_mapping[original_path] + + return base_mapping + + +def get_module_diffs(repo: str, base_tag: str, head_tag: str) -> Dict[str, Dict[str, any]]: + """ + Get diffs for relevant modules between two tags. + + Args: + repo: Repository in format 'owner/repo' + base_tag: Base tag (e.g., 'v11.0.0') + head_tag: Head tag (e.g., 'v12.0.0') + + Returns: + Dictionary mapping module paths to their diff information + """ + from check_noble_version.github import get_diff_between_tags + + # Get full diff between tags + diff_data = get_diff_between_tags(repo, base_tag, head_tag) + if not diff_data: + return {} + + # Build module-to-directory mapping dynamically, including replacement paths + # Use head_tag to get the most current replace directives + module_to_dir = _build_module_to_dir_mapping(repo, head_tag) + + # Filter files in the diff that belong to relevant modules + files = diff_data.get('files', []) + module_diffs = {} + + for file_info in files: + filename = file_info.get('filename', '') + + # Check if this file belongs to any relevant module directory + for module_path, module_dir in module_to_dir.items(): + if filename.startswith(module_dir + '/'): + if module_path not in module_diffs: + module_diffs[module_path] = { + 'files': [], + 'total_additions': 0, + 'total_deletions': 0, + 'total_changes': 0, + } + + module_diffs[module_path]['files'].append(file_info) + module_diffs[module_path]['total_additions'] += file_info.get('additions', 0) + module_diffs[module_path]['total_deletions'] += file_info.get('deletions', 0) + module_diffs[module_path]['total_changes'] += file_info.get('changes', 0) + break + + return module_diffs + diff --git a/scripts/check_noble_version/test_modules.py b/scripts/check_noble_version/test_modules.py new file mode 100644 index 0000000..0db9a3c --- /dev/null +++ b/scripts/check_noble_version/test_modules.py @@ -0,0 +1,241 @@ +"""Tests for modules.py""" + +import pytest +from check_noble_version.modules import ( + get_relevant_module_paths, + parse_go_mod, + parse_replace_directives, + apply_replace_directives, + get_relevant_modules_from_go_mod, + get_module_versions_for_tag, + get_module_diffs, + MODULE_MAPPINGS, +) + + +class TestGetRelevantModulePaths: + """Tests for get_relevant_module_paths function""" + + def test_returns_list_of_paths(self): + """Test that function returns a list of module paths""" + paths = get_relevant_module_paths() + assert isinstance(paths, list) + assert len(paths) > 0 + + def test_includes_known_modules(self): + """Test that known modules are included""" + paths = get_relevant_module_paths() + assert "dollar.noble.xyz" in paths or any("dollar" in p for p in paths) + assert any("orbiter" in p for p in paths) + assert any("forwarding" in p for p in paths) + + +class TestParseGoMod: + """Tests for parse_go_mod function""" + + def test_parse_simple_go_mod(self): + """Test parsing a simple go.mod file""" + go_mod = """ +module github.com/noble-assets/noble + +go 1.21 + +require ( + dollar.noble.xyz v1.0.0 + github.com/noble-assets/orbiter v2.0.0 +) +""" + modules = parse_go_mod(go_mod) + assert "dollar.noble.xyz" in modules + assert modules["dollar.noble.xyz"] == "v1.0.0" + assert "github.com/noble-assets/orbiter" in modules + assert modules["github.com/noble-assets/orbiter"] == "v2.0.0" + + def test_parse_go_mod_with_indirect(self): + """Test parsing go.mod with indirect dependencies""" + go_mod = """ +module github.com/noble-assets/noble + +require ( + dollar.noble.xyz v1.0.0 + github.com/noble-assets/orbiter v2.0.0 // indirect +) +""" + modules = parse_go_mod(go_mod) + assert "dollar.noble.xyz" in modules + assert "github.com/noble-assets/orbiter" in modules + + def test_parse_go_mod_single_line_require(self): + """Test parsing go.mod with single-line require""" + go_mod = """ +module github.com/noble-assets/noble + +require dollar.noble.xyz v1.0.0 +require github.com/noble-assets/orbiter v2.0.0 +""" + modules = parse_go_mod(go_mod) + assert "dollar.noble.xyz" in modules + assert "github.com/noble-assets/orbiter" in modules + + +class TestParseReplaceDirectives: + """Tests for parse_replace_directives function""" + + def test_parse_single_line_replace(self): + """Test parsing single-line replace directive""" + go_mod = """ +module github.com/noble-assets/noble + +replace github.com/circlefin/noble-cctp => github.com/noble-assets/cctp v1.0.0 +""" + replaces = parse_replace_directives(go_mod) + assert "github.com/circlefin/noble-cctp" in replaces + assert replaces["github.com/circlefin/noble-cctp"] == "github.com/noble-assets/cctp" + + def test_parse_replace_block(self): + """Test parsing replace directives in a block""" + go_mod = """ +module github.com/noble-assets/noble + +replace ( + github.com/circlefin/noble-cctp => github.com/noble-assets/cctp v1.0.0 + dollar.noble.xyz => github.com/noble-assets/dollar v2.0.0 +) +""" + replaces = parse_replace_directives(go_mod) + assert "github.com/circlefin/noble-cctp" in replaces + assert replaces["github.com/circlefin/noble-cctp"] == "github.com/noble-assets/cctp" + assert "dollar.noble.xyz" in replaces + assert replaces["dollar.noble.xyz"] == "github.com/noble-assets/dollar" + + def test_parse_replace_with_version(self): + """Test parsing replace directive with version on both sides""" + go_mod = """ +module github.com/noble-assets/noble + +replace github.com/circlefin/noble-cctp v1.0.0 => github.com/noble-assets/cctp v1.0.0 +""" + replaces = parse_replace_directives(go_mod) + assert "github.com/circlefin/noble-cctp" in replaces + assert replaces["github.com/circlefin/noble-cctp"] == "github.com/noble-assets/cctp" + + def test_parse_replace_with_local_path(self): + """Test parsing replace directive with local path""" + go_mod = """ +module github.com/noble-assets/noble + +replace github.com/circlefin/noble-cctp => ../local/cctp +""" + replaces = parse_replace_directives(go_mod) + assert "github.com/circlefin/noble-cctp" in replaces + assert replaces["github.com/circlefin/noble-cctp"] == "../local/cctp" + + +class TestApplyReplaceDirectives: + """Tests for apply_replace_directives function""" + + def test_applies_replace(self): + """Test that replace directives are applied""" + modules = { + "github.com/circlefin/noble-cctp": "v1.0.0", + "dollar.noble.xyz": "v2.0.0", + } + replaces = { + "github.com/circlefin/noble-cctp": "github.com/noble-assets/cctp", + } + + result = apply_replace_directives(modules, replaces) + assert "github.com/noble-assets/cctp" in result + assert result["github.com/noble-assets/cctp"] == "v1.0.0" + assert "github.com/circlefin/noble-cctp" not in result + assert "dollar.noble.xyz" in result # Not replaced, so kept + + def test_keeps_non_replaced_modules(self): + """Test that modules without replace directives are kept""" + modules = { + "github.com/noble-assets/orbiter": "v2.0.0", + "dollar.noble.xyz": "v1.0.0", + } + replaces = {} + + result = apply_replace_directives(modules, replaces) + assert result == modules + + +class TestGetRelevantModulesFromGoMod: + """Tests for get_relevant_modules_from_go_mod function""" + + def test_filters_relevant_modules(self): + """Test that only relevant modules are returned""" + go_mod = """ +module github.com/noble-assets/noble + +require ( + dollar.noble.xyz v1.0.0 + github.com/noble-assets/orbiter v2.0.0 + github.com/cosmos/cosmos-sdk v0.50.0 + github.com/some/other/module v1.0.0 +) +""" + relevant = get_relevant_modules_from_go_mod(go_mod) + assert "dollar.noble.xyz" in relevant + assert "github.com/noble-assets/orbiter" in relevant + assert "github.com/cosmos/cosmos-sdk" not in relevant + assert "github.com/some/other/module" not in relevant + + def test_applies_replace_directives(self): + """Test that replace directives are applied before filtering""" + go_mod = """ +module github.com/noble-assets/noble + +require ( + github.com/circlefin/noble-cctp v1.0.0 + dollar.noble.xyz v2.0.0 +) + +replace github.com/circlefin/noble-cctp => github.com/noble-assets/cctp v1.0.0 +""" + relevant = get_relevant_modules_from_go_mod(go_mod) + # Should use the replacement path, not the original + assert "github.com/noble-assets/cctp" in relevant + assert relevant["github.com/noble-assets/cctp"] == "v1.0.0" + # Original path should not be in results (replaced) + assert "github.com/circlefin/noble-cctp" not in relevant + assert "dollar.noble.xyz" in relevant + + def test_replace_takes_precedence(self): + """Test that replace directive takes precedence over require""" + go_mod = """ +module github.com/noble-assets/noble + +require ( + github.com/circlefin/noble-cctp v1.0.0 +) + +replace github.com/circlefin/noble-cctp => github.com/noble-assets/cctp v1.0.0 +""" + relevant = get_relevant_modules_from_go_mod(go_mod) + # Should only have the replacement, not the original + assert "github.com/noble-assets/cctp" in relevant + assert "github.com/circlefin/noble-cctp" not in relevant + + +class TestGetModuleVersionsForTag: + """Tests for get_module_versions_for_tag function""" + + def test_returns_none_for_invalid_tag(self): + """Test that function returns None for invalid tag""" + result = get_module_versions_for_tag("noble-assets/noble", "v999.999.999") + # This might return None if tag doesn't exist, or empty dict if go.mod exists but has no relevant modules + assert result is None or isinstance(result, dict) + + +class TestGetModuleDiffs: + """Tests for get_module_diffs function""" + + def test_returns_dict(self): + """Test that function returns a dictionary""" + # This will likely return empty dict for invalid tags, but should not crash + result = get_module_diffs("noble-assets/noble", "v999.999.999", "v999.999.998") + assert isinstance(result, dict) +