Last active
November 24, 2025 21:38
-
-
Save agritheory/28fa94da76d70df384f3bd8ef404ca4a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Copyright (c) 2025, AgriTheory and contributors | |
| # For license information, please see license.txt | |
| """ | |
| Frappe App Version-16 Migration Script | |
| This script automates the migration of Frappe apps from version-15 to version-16. | |
| It uses PyGithub to create branches and update files programmatically. | |
| Usage: | |
| python migrate_to_v16.py --repos repo1,repo2,repo3 --org AgriTheory --token $GITHUB_TOKEN | |
| python migrate_to_v16.py --repos fleet --org AgriTheory --token $GITHUB_TOKEN | |
| """ | |
| import argparse | |
| import base64 | |
| import re | |
| import sys | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| import toml | |
| from github import Github, GithubException | |
| from github.Repository import Repository | |
| @dataclass | |
| class MigrationConfig: | |
| """Configuration for the migration process.""" | |
| source_branch: str = "version-15" | |
| target_branch: str = "version-16" | |
| python_version: str = "3.13" | |
| python_version_range: str = ">=3.10,<3.14" | |
| node_version: str = "22" | |
| frappe_dependency: str = ">=16.0.0-dev,<17.0.0" | |
| initial_version: str = "16.0.0" | |
| commit_message: str = "chore: migrate to version-16" | |
| @dataclass | |
| class FileUpdate: | |
| """Represents a file update to be made.""" | |
| path: str | |
| content: str | |
| message: str | |
| sha: Optional[str] = None | |
| @dataclass | |
| class MigrationResult: | |
| """Result of a migration attempt.""" | |
| repo_name: str | |
| success: bool | |
| branch_created: bool = False | |
| files_updated: list[str] = field(default_factory=list) | |
| errors: list[str] = field(default_factory=list) | |
| warnings: list[str] = field(default_factory=list) | |
| class FrappeAppMigrator: | |
| """Handles migration of Frappe apps to version-16.""" | |
| def __init__(self, github_token: str, org: str, config: MigrationConfig): | |
| self.gh = Github(github_token) | |
| self.org = org | |
| self.config = config | |
| def migrate_repo(self, repo_name: str) -> MigrationResult: | |
| """Migrate a single repository to version-16.""" | |
| result = MigrationResult(repo_name=repo_name, success=False) | |
| try: | |
| repo = self.gh.get_repo(f"{self.org}/{repo_name}") | |
| print(f"\n{'='*60}") | |
| print(f"Migrating: {repo.full_name}") | |
| print(f"{'='*60}") | |
| # Check if source branch exists | |
| if not self._branch_exists(repo, self.config.source_branch): | |
| result.errors.append(f"Source branch '{self.config.source_branch}' not found") | |
| return result | |
| # Check if target branch already exists | |
| if self._branch_exists(repo, self.config.target_branch): | |
| result.warnings.append( | |
| f"Target branch '{self.config.target_branch}' already exists" | |
| ) | |
| print(f" ⚠️ Branch {self.config.target_branch} already exists, updating files...") | |
| else: | |
| self._create_branch(repo, self.config.source_branch, self.config.target_branch) | |
| result.branch_created = True | |
| print(f" ✅ Created branch: {self.config.target_branch}") | |
| # Collect all file updates | |
| updates = self._collect_file_updates(repo) | |
| # Apply updates | |
| for update in updates: | |
| try: | |
| self._update_file(repo, update) | |
| print(f" ✅ Updated: {update.path}") | |
| result.files_updated.append(update.path) | |
| except Exception as e: | |
| result.errors.append(f"Failed to update {update.path}: {str(e)}") | |
| print(f" ❌ Failed to update {update.path}: {e}") | |
| result.success = len(result.errors) == 0 | |
| return result | |
| except GithubException as e: | |
| result.errors.append(f"GitHub API error: {str(e)}") | |
| return result | |
| except Exception as e: | |
| result.errors.append(f"Unexpected error: {str(e)}") | |
| return result | |
| def _branch_exists(self, repo: Repository, branch_name: str) -> bool: | |
| """Check if a branch exists in the repository.""" | |
| try: | |
| repo.get_branch(branch_name) | |
| return True | |
| except GithubException: | |
| return False | |
| def _create_branch(self, repo: Repository, source: str, target: str) -> None: | |
| """Create a new branch from source.""" | |
| source_branch = repo.get_branch(source) | |
| repo.create_git_ref(f"refs/heads/{target}", source_branch.commit.sha) | |
| def _get_file_content(self, repo: Repository, path: str) -> tuple[str, str]: | |
| """Get file content and SHA from repo.""" | |
| try: | |
| content = repo.get_contents(path, ref=self.config.target_branch) | |
| if isinstance(content, list): | |
| raise ValueError(f"Path {path} is a directory") | |
| return base64.b64decode(content.content).decode("utf-8"), content.sha | |
| except GithubException: | |
| return None, None | |
| def _update_file(self, repo: Repository, update: FileUpdate) -> None: | |
| """Update or create a file in the repository.""" | |
| try: | |
| existing = repo.get_contents(update.path, ref=self.config.target_branch) | |
| sha = existing.sha if not isinstance(existing, list) else None | |
| repo.update_file( | |
| path=update.path, | |
| message=update.message, | |
| content=update.content, | |
| sha=sha, | |
| branch=self.config.target_branch, | |
| ) | |
| except GithubException as e: | |
| if e.status == 404: | |
| repo.create_file( | |
| path=update.path, | |
| message=update.message, | |
| content=update.content, | |
| branch=self.config.target_branch, | |
| ) | |
| else: | |
| raise | |
| def _collect_file_updates(self, repo: Repository) -> list[FileUpdate]: | |
| """Collect all file updates needed for migration.""" | |
| updates = [] | |
| pyproject_update = self._update_pyproject_toml(repo) | |
| if pyproject_update: | |
| updates.append(pyproject_update) | |
| init_update = self._update_init_version(repo) | |
| if init_update: | |
| updates.append(init_update) | |
| workflow_updates = self._update_github_workflows(repo) | |
| updates.extend(workflow_updates) | |
| install_update = self._update_install_script(repo) | |
| if install_update: | |
| updates.append(install_update) | |
| readme_update = self._update_readme(repo) | |
| if readme_update: | |
| updates.append(readme_update) | |
| return updates | |
| def _update_pyproject_toml(self, repo: Repository) -> Optional[FileUpdate]: | |
| """Update pyproject.toml for version-16.""" | |
| content, sha = self._get_file_content(repo, "pyproject.toml") | |
| if not content: | |
| print(f" ⚠️ pyproject.toml not found") | |
| return None | |
| try: | |
| data = toml.loads(content) | |
| except Exception as e: | |
| print(f" ⚠️ Failed to parse pyproject.toml: {e}") | |
| return None | |
| modified = False | |
| # Update project.requires-python if present | |
| if "project" in data and "requires-python" in data["project"]: | |
| data["project"]["requires-python"] = self.config.python_version_range | |
| modified = True | |
| # Update tool.poetry.version if present | |
| if "tool" in data and "poetry" in data["tool"]: | |
| if "version" in data["tool"]["poetry"]: | |
| data["tool"]["poetry"]["version"] = self.config.initial_version | |
| modified = True | |
| if "dependencies" in data["tool"]["poetry"]: | |
| if "python" in data["tool"]["poetry"]["dependencies"]: | |
| data["tool"]["poetry"]["dependencies"]["python"] = self.config.python_version_range | |
| modified = True | |
| # Add/update tool.bench.frappe-dependencies | |
| if "tool" not in data: | |
| data["tool"] = {} | |
| if "bench" not in data["tool"]: | |
| data["tool"]["bench"] = {} | |
| if "frappe-dependencies" not in data["tool"]["bench"]: | |
| data["tool"]["bench"]["frappe-dependencies"] = {} | |
| data["tool"]["bench"]["frappe-dependencies"]["frappe"] = self.config.frappe_dependency | |
| modified = True | |
| # Update semantic_release branches configuration | |
| if "tool" in data and "semantic_release" in data["tool"]: | |
| sr = data["tool"]["semantic_release"] | |
| if "branches" in sr and "version" in sr["branches"]: | |
| current_match = sr["branches"]["version"].get("match", "") | |
| if "16" not in current_match: | |
| # Update pattern to include version-16 | |
| new_match = re.sub( | |
| r"version-\(([\d|]+)\)", lambda m: f"version-({m.group(1)}|16)", current_match | |
| ) | |
| if new_match != current_match: | |
| sr["branches"]["version"]["match"] = new_match | |
| modified = True | |
| # Update ruff target-version if present | |
| if "tool" in data and "ruff" in data["tool"]: | |
| if "target-version" in data["tool"]["ruff"]: | |
| data["tool"]["ruff"]["target-version"] = "py313" | |
| modified = True | |
| if not modified: | |
| return None | |
| new_content = toml.dumps(data) | |
| return FileUpdate( | |
| path="pyproject.toml", | |
| content=new_content, | |
| message=f"{self.config.commit_message}\n\nUpdated pyproject.toml for version-16 compatibility", | |
| sha=sha, | |
| ) | |
| def _update_init_version(self, repo: Repository) -> Optional[FileUpdate]: | |
| """Update __version__ in the app's __init__.py.""" | |
| app_name = repo.name.replace("-", "_") | |
| possible_paths = [ | |
| f"{app_name}/__init__.py", | |
| f"{repo.name}/__init__.py", | |
| ] | |
| for path in possible_paths: | |
| content, sha = self._get_file_content(repo, path) | |
| if content: | |
| new_content = re.sub( | |
| r'__version__\s*=\s*["\'][\d.]+["\']', | |
| f'__version__ = "{self.config.initial_version}"', | |
| content, | |
| ) | |
| if new_content != content: | |
| return FileUpdate( | |
| path=path, | |
| content=new_content, | |
| message=f"{self.config.commit_message}\n\nBumped version to {self.config.initial_version}", | |
| sha=sha, | |
| ) | |
| return None | |
| def _update_github_workflows(self, repo: Repository) -> list[FileUpdate]: | |
| """Update GitHub Actions workflow files.""" | |
| updates = [] | |
| try: | |
| contents = repo.get_contents(".github/workflows", ref=self.config.target_branch) | |
| if not isinstance(contents, list): | |
| contents = [contents] | |
| for item in contents: | |
| if item.type != "file" or not item.name.endswith((".yml", ".yaml")): | |
| continue | |
| content, sha = self._get_file_content(repo, item.path) | |
| if not content: | |
| continue | |
| new_content = self._update_workflow_content(content) | |
| if new_content != content: | |
| updates.append( | |
| FileUpdate( | |
| path=item.path, | |
| content=new_content, | |
| message=f"{self.config.commit_message}\n\nUpdated {item.name} for version-16", | |
| sha=sha, | |
| ) | |
| ) | |
| except GithubException: | |
| print(f" ⚠️ No .github/workflows directory found") | |
| return updates | |
| def _update_readme(self, repo: Repository) -> Optional[FileUpdate]: | |
| """Update README.md branch and version references.""" | |
| content, sha = self._get_file_content(repo, "README.md") | |
| if not content: | |
| return None | |
| new_content = content | |
| # Update branch references: --branch version-15 → --branch version-16 | |
| new_content = re.sub(r"--branch\s+version-15", "--branch version-16", new_content) | |
| # Update --frappe-branch references | |
| new_content = re.sub( | |
| r"--frappe-branch\s+version-15", "--frappe-branch version-16", new_content | |
| ) | |
| # Update Python version references (e.g., "3.10 latest" or "3.10.x") | |
| new_content = re.sub( | |
| r"(\bpython\b.*?)3\.10(\.\d+)?", | |
| rf"\g<1>{self.config.python_version}", | |
| new_content, | |
| flags=re.IGNORECASE, | |
| ) | |
| if new_content == content: | |
| return None | |
| return FileUpdate( | |
| path="README.md", | |
| content=new_content, | |
| message=f"{self.config.commit_message}\n\nUpdated README.md branch references", | |
| sha=sha, | |
| ) | |
| def _update_workflow_content(self, content: str) -> str: | |
| """Update workflow YAML content for version-16.""" | |
| new_content = content | |
| # Update branch triggers to include version-16 | |
| branch_patterns = [ | |
| # branches:\n - version-14\n - version-15 | |
| ( | |
| r"(branches:\s*\n(?:\s*-\s*version-\d+\s*\n)*\s*-\s*version-15)(\s*\n)", | |
| r"\1\n - version-16\2", | |
| ), | |
| # branches: [version-14, version-15] | |
| (r"(branches:\s*\[)([^\]]*version-15)(\])", r"\1\2, version-16\3"), | |
| ] | |
| for pattern, replacement in branch_patterns: | |
| new_content = re.sub(pattern, replacement, new_content) | |
| # Update Python version | |
| new_content = re.sub( | |
| r"(python-version:\s*['\"]?)3\.(10|11|12)(['\"]?)", | |
| rf"\g<1>{self.config.python_version}\3", | |
| new_content, | |
| ) | |
| # Update Node version | |
| new_content = re.sub( | |
| r"(node-version:\s*)(\d+)", rf"\g<1>{self.config.node_version}", new_content | |
| ) | |
| # Update actions versions | |
| action_updates = { | |
| "actions/checkout@v3": "actions/checkout@v4", | |
| "actions/checkout@v2": "actions/checkout@v4", | |
| "actions/setup-python@v4": "actions/setup-python@v5", | |
| "actions/setup-python@v3": "actions/setup-python@v5", | |
| "actions/setup-python@v2": "actions/setup-python@v5", | |
| "actions/setup-node@v3": "actions/setup-node@v4", | |
| "actions/setup-node@v2": "actions/setup-node@v4", | |
| "actions/cache@v3": "actions/cache@v4", | |
| "actions/cache@v2": "actions/cache@v4", | |
| } | |
| for old, new in action_updates.items(): | |
| new_content = new_content.replace(old, new) | |
| return new_content | |
| def _update_install_script(self, repo: Repository) -> Optional[FileUpdate]: | |
| """Update .github/helper/install.sh for version-16.""" | |
| path = ".github/helper/install.sh" | |
| content, sha = self._get_file_content(repo, path) | |
| if not content: | |
| return None | |
| new_content = re.sub(r"--branch\s+version-15", "--branch ${BRANCH_NAME}", content) | |
| if new_content != content: | |
| return FileUpdate( | |
| path=path, | |
| content=new_content, | |
| message=f"{self.config.commit_message}\n\nUpdated install.sh branch references", | |
| sha=sha, | |
| ) | |
| return None | |
| def parse_args(): | |
| """Parse command line arguments.""" | |
| parser = argparse.ArgumentParser( | |
| description="Migrate Frappe apps to version-16", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| python migrate_to_v16.py --repos fleet --org AgriTheory --token $GITHUB_TOKEN | |
| python migrate_to_v16.py --repos fleet,inventory_tools,check_run --org AgriTheory --token $GITHUB_TOKEN | |
| python migrate_to_v16.py --repos fleet --org AgriTheory --token $GITHUB_TOKEN --dry-run | |
| """, | |
| ) | |
| parser.add_argument( | |
| "--repos", | |
| type=str, | |
| required=True, | |
| help="Comma-separated list of repository names", | |
| ) | |
| parser.add_argument( | |
| "--org", | |
| type=str, | |
| required=True, | |
| help="GitHub organization name", | |
| ) | |
| parser.add_argument( | |
| "--token", | |
| type=str, | |
| required=True, | |
| help="GitHub personal access token", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Print what would be done without making changes", | |
| ) | |
| parser.add_argument( | |
| "--python-version", | |
| type=str, | |
| default="3.13", | |
| help="Python version for workflows (default: 3.13)", | |
| ) | |
| parser.add_argument( | |
| "--node-version", | |
| type=str, | |
| default="22", | |
| help="Node.js version for workflows (default: 22)", | |
| ) | |
| return parser.parse_args() | |
| def main(): | |
| """Main entry point.""" | |
| args = parse_args() | |
| repos = [r.strip() for r in args.repos.split(",") if r.strip()] | |
| if not repos: | |
| print("Error: No repositories specified") | |
| sys.exit(1) | |
| config = MigrationConfig( | |
| python_version=args.python_version, | |
| node_version=args.node_version, | |
| ) | |
| print(f"\n🚀 Frappe Version-16 Migration Script") | |
| print(f"{'='*60}") | |
| print(f"Organization: {args.org}") | |
| print(f"Repositories: {', '.join(repos)}") | |
| print(f"Source branch: {config.source_branch}") | |
| print(f"Target branch: {config.target_branch}") | |
| print(f"Python version: {config.python_version}") | |
| print(f"Node version: {config.node_version}") | |
| migrator = FrappeAppMigrator(args.token, args.org, config) | |
| results = [] | |
| for repo in repos: | |
| result = migrator.migrate_repo(repo) | |
| results.append(result) | |
| # Print summary | |
| print(f"\n{'='*60}") | |
| print("Migration Summary") | |
| print(f"{'='*60}") | |
| successful = [r for r in results if r.success] | |
| failed = [r for r in results if not r.success] | |
| print(f"\n✅ Successful: {len(successful)}") | |
| for r in successful: | |
| print(f" - {r.repo_name}: {len(r.files_updated)} files updated") | |
| if r.warnings: | |
| for w in r.warnings: | |
| print(f" ⚠️ {w}") | |
| if failed: | |
| print(f"\n❌ Failed: {len(failed)}") | |
| for r in failed: | |
| print(f" - {r.repo_name}") | |
| for e in r.errors: | |
| print(f" Error: {e}") | |
| return 0 if not failed else 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment