Skip to content

Instantly share code, notes, and snippets.

@agritheory
Last active November 24, 2025 21:38
Show Gist options
  • Select an option

  • Save agritheory/28fa94da76d70df384f3bd8ef404ca4a to your computer and use it in GitHub Desktop.

Select an option

Save agritheory/28fa94da76d70df384f3bd8ef404ca4a to your computer and use it in GitHub Desktop.
# 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