diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..8a11402 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,34 @@ +[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 --clear", + "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" + +[tasks.test] +description = "Run pytest unit tests" +run = "uv run pytest scripts/check_noble_version/test_*.py -v" + diff --git a/.noble_version_tracker.json b/.noble_version_tracker.json new file mode 100644 index 0000000..cfe103e --- /dev/null +++ b/.noble_version_tracker.json @@ -0,0 +1,4 @@ +{ + "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/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..6ed9cd9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31.0 +pytest>=8.0.0 + diff --git a/scripts/check_noble_version.py b/scripts/check_noble_version.py new file mode 100755 index 0000000..f25e29f --- /dev/null +++ b/scripts/check_noble_version.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +""" +Noble Documentation Version Tracker + +Entry point script that delegates to the package CLI. +""" + +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..e5f9b0f --- /dev/null +++ b/scripts/check_noble_version/__init__.py @@ -0,0 +1,42 @@ +""" +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.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, + 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", + "get_relevant_module_paths", + "get_module_versions_for_tag", + "get_module_diffs", + "MODULE_MAPPINGS", + "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..df89953 --- /dev/null +++ b/scripts/check_noble_version/cli.py @@ -0,0 +1,138 @@ +"""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 +from check_noble_version.modules import ( + get_module_versions_for_tag, + get_module_diffs, +) + + +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) + + # 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:") + 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/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/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_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) + 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 +