Files
TheRock/rocm-systems/.github/scripts/pr_merge_sync_patches.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

577 lines
22 KiB
Python

#!/usr/bin/env python3
"""
Sync Patches to Subrepositories
-------------------------------
This script is part of the super-repo synchronization system. It runs after a super-repo pull request
is merged and applies relevant changes to the corresponding sub-repositories using Git patches.
- Uses the merge commit of the super-repo PR to extract subtree changes.
- Generates patch files per changed subtree.
- Applies each patch to its respective sub-repository, adjusting for subtree prefix.
- Uses the repos-config.json file to map subtrees to sub-repos.
- Assumes this script is run from the root of the super-repo.
Arguments:
--repo : Full repository name (e.g., org/repo)
--pr : Pull request number
--subtrees : A newline-separated list of subtree paths in category/name format (e.g., projects/rocBLAS)
--config : OPTIONAL, path to the repos-config.json file
--dry-run : If set, will only log actions without making changes.
--debug : If set, enables detailed debug logging.
Example Usage:
python pr_merge_sync_patches.py --repo ROCm/rocm-systems --pr 123 --subtrees "$(printf 'projects/rocprofiler-sdk\\nprojects/rocprofiler-register\\nprojects/rocm-smi-lib')" --dry-run --debug
"""
import argparse
import logging
import os
import re
import shutil
import subprocess
import tempfile
from email.header import decode_header
from typing import Optional, List
from pathlib import Path
from github_cli_client import GitHubCLIClient
from config_loader import load_repo_config
from repo_config_model import RepoEntry
logger = logging.getLogger(__name__)
def parse_arguments(argv: Optional[List[str]] = None) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Apply subtree patches to sub-repositories."
)
parser.add_argument(
"--repo", required=True, help="Full repository name (e.g., org/repo)"
)
parser.add_argument("--pr", required=True, type=int, help="Pull request number")
parser.add_argument(
"--subtrees",
required=True,
help="Newline-separated list of changed subtrees (category/name)",
)
parser.add_argument(
"--config",
required=False,
default=".github/repos-config.json",
help="Path to the repos-config.json file",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="If set, only logs actions without making changes.",
)
parser.add_argument(
"--no-push",
action="store_true",
help="Clone, apply patches, and commit locally but do not push to remote (mock run).",
)
parser.add_argument(
"--keep-patches-dir",
metavar="DIR",
default=None,
help="Write generated patch files to DIR (e.g. ./patches) for inspection instead of a temp dir.",
)
parser.add_argument(
"--keep-clone-dir",
metavar="DIR",
default=None,
help="Clone the subrepo into DIR instead of a temp dir (implies --no-push).",
)
parser.add_argument(
"--debug", action="store_true", help="If set, enables detailed debug logging."
)
return parser.parse_args(argv)
def get_subtree_info(config: List[RepoEntry], subtrees: List[str]) -> List[RepoEntry]:
"""Return config entries matching the given subtrees in category/name format."""
requested = set(subtrees)
matched = [
entry for entry in config if f"{entry.category}/{entry.name}" in requested
]
missing = requested - {f"{e.category}/{e.name}" for e in matched}
if missing:
logger.warning(
f"Some subtrees not found in config: {', '.join(sorted(missing))}"
)
return matched
def _get_patch_range(merge_sha: str) -> tuple[str, str]:
"""Derive the commit range for patch-back from the merge commit.
- Squash merge (1 parent): range = parent..merge (single squashed commit).
- Rebase and merge (1 parent): range = parent..merge; only the tip commit is
included (earlier rebased commits are not patched back; limitation).
- Merge commit (2 parents): range = first_parent..second_parent (PR branch only).
Returns: (base_sha, range_end) so the range is base_sha..range_end.
"""
out = _run_git(["rev-list", "--parents", "-n", "1", merge_sha])
parts = out.split()
if len(parts) < 2:
raise RuntimeError(f"Could not read parents of merge commit {merge_sha}")
merge_full = parts[0]
parents = parts[1:]
if len(parents) == 2:
# Merge commit: first parent = base at merge time, second = PR branch tip
return parents[0], parents[1]
if len(parents) == 1:
# One parent: squash (1 commit) or rebase-and-merge (N commits). The range is
# parent..merge (exclusive of parent), which is exactly the PR's commits.
return parents[0], merge_full
raise RuntimeError(
f"Merge commit {merge_sha} has {len(parents)} parents; expected 1 or 2."
)
def _run_git(args: List[str], cwd: Optional[Path] = None) -> str:
"""Run a git command and return stdout."""
cmd = ["git"] + args
logger.debug(f"Running git command: {' '.join(cmd)} (cwd={cwd})")
result = subprocess.run(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
logger.error(f"Git command failed: {' '.join(cmd)}\n{result.stderr}")
raise RuntimeError(f"Git command failed: {' '.join(cmd)}\n{result.stderr}")
return result.stdout.strip()
def _clone_subrepo(repo_url: str, branch: str, destination: Path) -> None:
"""Clone a specific branch from the given GitHub repository into the destination path."""
_run_git(
[
"clone",
"--branch",
branch,
"--single-branch",
f"https://github.com/{repo_url}",
str(destination),
]
)
logger.debug(f"Cloned {repo_url} into {destination}")
def _configure_git_user(repo_path: Path) -> None:
"""Configure git user.name and user.email for the given repository directory."""
_run_git(["config", "user.name", "systems-assistant[bot]"], cwd=repo_path)
_run_git(
["config", "user.email", "systems-assistant[bot]@users.noreply.github.com"],
cwd=repo_path,
)
def _apply_patch(repo_path: Path, patch_path: Path) -> bool:
"""Apply a patch file to the working tree. Use --allow-empty so patches that
touch no files in the subtree (e.g. empty or path-filtered) do not fail.
Returns True if the patch applied with changes, False if it was empty/no-op.
"""
_run_git(["apply", "--allow-empty", str(patch_path)], cwd=repo_path)
# git apply only modifies working tree (does not stage); include untracked (new files)
has_changes = _run_git(["status", "--porcelain"], cwd=repo_path).strip()
if has_changes:
logger.info(f"Applied patch to working tree at {repo_path}")
else:
logger.debug(
f"Patch produced no changes in working tree at {repo_path}, skipping commit"
)
return bool(has_changes)
def _stage_changes(repo_path: Path) -> None:
"""Stage all changes in the repository."""
_run_git(["add", "."], cwd=repo_path)
logger.debug(f"Staged all changes in {repo_path}")
def _decode_rfc2047_header(value: str) -> str:
"""Decode RFC 2047 encoded-word (e.g. =?UTF-8?q?foo=20bar?=) to plain text."""
if "=?" not in value:
return value
parts = decode_header(value)
result = []
for part, charset in parts:
if isinstance(part, bytes):
result.append(part.decode(charset or "utf-8", errors="replace"))
else:
result.append(part or "")
return "".join(result)
def _extract_commit_message_from_patch(patch_path: Path) -> str:
"""Extract and clean the original commit message from the patch file,
removing '[PATCH]' and trailing PR references like (#NN) from the title.
Decodes RFC 2047 (MIME) encoded subjects so subrepo commits show plain text."""
with open(patch_path, "r", encoding="utf-8") as f:
lines = f.readlines()
commit_msg_lines = []
in_msg = False
for line in lines:
if line.startswith("Subject: "):
subject = line[len("Subject: ") :].strip()
subject = _decode_rfc2047_header(subject)
# Remove leading "[PATCH]" if present
if subject.startswith("[PATCH]"):
subject = subject[len("[PATCH]") :].strip()
# Remove trailing PR refs like (#NN)
subject = re.sub(r"\s*\(#\d+\)$", "", subject)
commit_msg_lines.append(subject + "\n")
in_msg = True
elif in_msg:
if line.startswith("---"):
break
commit_msg_lines.append(line)
return "".join(commit_msg_lines).strip()
def _format_commit_message(
super_repo_url: str, pr_number: int, merge_sha: str, original_msg: str
) -> str:
"""Append a sync annotation to the original commit message."""
annotation = (
f"\n[rocm-systems] {super_repo_url}#{pr_number} (commit {merge_sha[:7]})\n"
)
return original_msg + annotation
def _commit_changes(
repo_path: Path, message: str, author_name: str, author_email: str
) -> None:
"""Commit staged changes with the specified author and message."""
_run_git(
["commit", "--author", f"{author_name} <{author_email}>", "-m", message],
cwd=repo_path,
)
logger.debug(f"Committed changes with author {author_name} <{author_email}>")
def _set_authenticated_remote(repo_path: Path, repo_url: str) -> None:
"""Set the push URL to use the GitHub App token from GH_TOKEN env."""
token = os.environ["GH_TOKEN"]
if not token:
raise RuntimeError("GH_TOKEN environment variable is not set")
remote_url = f"https://x-access-token:{token}@github.com/{repo_url}.git"
_run_git(["remote", "set-url", "origin", remote_url], cwd=repo_path)
def _push_changes(repo_path: Path, branch: str) -> None:
"""Push the commit to origin of branch."""
_run_git(["push", "origin", branch], cwd=repo_path)
logger.debug(f"Pushed changes from {repo_path} to origin")
def _commits_touching_prefix(base_sha: str, merge_sha: str, prefix: str) -> List[str]:
"""Return full SHAs of commits in base..merge that touch the prefix (oldest first)."""
path_arg = prefix.rstrip("/")
out = _run_git(
["rev-list", "--reverse", f"{base_sha}..{merge_sha}", "--", path_arg]
)
return [s.strip() for s in out.splitlines() if s.strip()]
def _patch_has_hunks(patch_path: Path) -> bool:
"""Return True if the patch file contains at least one diff hunk (---/+++ and lines)."""
with open(patch_path, "r", encoding="utf-8") as f:
content = f.read()
# Git patch has "--- a/path" and "+++ b/path" then "@@ ... @@" hunks
return "--- " in content and "+++ " in content and "@@ " in content
def _patch_touched_paths(patch_path: Path) -> List[str]:
"""Return list of file paths touched in the patch (from --- / +++ lines)."""
paths = []
with open(patch_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("--- ") or line.startswith("+++ "):
# "--- a/clr/foo.c" or "+++ b/clr/foo.c" -> clr/foo.c
p = line[4:].strip().split("\t")[0]
if p.startswith("a/") or p.startswith("b/"):
p = p[2:]
if p != "/dev/null" and p not in paths:
paths.append(p)
return paths
def generate_patch(
prefix: str,
patch_path: Path,
base_sha: str,
range_end: str,
debug: bool = False,
) -> Optional[List[Path]]:
"""Generate patch file(s) for a given subtree prefix from a commit range.
Only commits that actually touch files under the prefix are included, so every
generated patch has at least one diff hunk and can be applied to the subrepo.
Only commits that actually touch files under the prefix are included, so every
generated patch has at least one diff hunk and can be applied to the subrepo.
Args:
prefix: The subtree prefix (e.g., "projects/rocBLAS/")
patch_path: Path where patch file(s) should be written
base_sha: Start of range (exclusive)
range_end: End of range (inclusive). For squash: merge SHA; for merge commit: merge^2 (second parent).
debug: If True, log which commits touch the prefix and each patch's paths.
Returns:
List[Path]: List of patch file paths (one per commit that touches the subtree)
None: If there are no commits that touch the prefix
"""
path_arg = prefix.rstrip("/")
total_commits = int(_run_git(["rev-list", "--count", f"{base_sha}..{range_end}"]))
commits = _commits_touching_prefix(base_sha, range_end, prefix)
if debug:
logger.debug(
f"Commit range {base_sha}..{range_end}: {total_commits} total commit(s), "
f"{len(commits)} commit(s) touch subtree '{path_arg}'"
)
for i, sha in enumerate(commits, 1):
subject = _run_git(["log", "-1", "--format=%s", sha])
logger.debug(f" [{i}] {sha[:7]} {subject}")
if not commits:
logger.debug(
f"No commits in {base_sha}..{range_end} touch prefix '{prefix}', skipping"
)
return None
patch_dir = patch_path.parent
# Generate one patch per commit that touches the subtree (preserves order)
for i, sha in enumerate(commits, 1):
_run_git(
[
"format-patch",
"-1",
sha,
f"--relative={prefix}",
"--output-directory",
str(patch_dir),
"--start-number",
str(i),
]
)
patch_files = sorted(patch_dir.glob("*.patch"))
if not patch_files:
raise RuntimeError(
f"No patch files generated for range {base_sha}..{range_end} with prefix '{prefix}' "
f"(expected patches for {len(commits)} commit(s) touching subtree)."
)
if debug:
for p in patch_files:
paths = _patch_touched_paths(p)
has_hunks = _patch_has_hunks(p)
logger.debug(f"Patch {p.name}: has_hunks={has_hunks}, paths={paths!r}")
logger.debug(
f"Generated {len(patch_files)} patch file(s) for prefix '{prefix}' "
f"(all touch subtree; {total_commits} total commit(s) in range)"
)
return patch_files
def resolve_patch_author(
client: GitHubCLIClient, repo: str, pr: int
) -> tuple[str, str]:
"""Determine the appropriate author for the patch
Returns: (author_name, author_email)"""
pr_data = client.get_pr_by_number(repo, pr)
body = pr_data.get("body", "") or ""
match = re.search(r"Originally authored by @([A-Za-z0-9_-]+)", body)
if match:
username = match.group(1)
logger.debug(f"Found originally authored username in PR body: @{username}")
else:
username = pr_data["user"]["login"]
logger.debug(f"No explicit original author, using PR author: @{username}")
name, email = client.get_user(username)
return name or username, email
def apply_patch_to_subrepo(
entry: RepoEntry,
super_repo_url: str,
super_repo_pr: int,
patch_paths: List[Path],
author_name: str,
author_email: str,
merge_sha: str,
dry_run: bool = False,
no_push: bool = False,
keep_clone_dir: Optional[Path] = None,
) -> None:
"""Clone the subrepo, apply patch(es), and attribute to the original author with commit message annotations.
Args:
entry: Repository entry configuration
super_repo_url: URL of the super repository
super_repo_pr: PR number in the super repository
patch_paths: List of patch file paths
author_name: Author name for commits
author_email: Author email for commits
merge_sha: Merge commit SHA
dry_run: If True, only log actions without making changes
no_push: If True, do not push after committing (mock run)
keep_clone_dir: If set, clone into this path instead of a temp dir (dir/entry.name); not cleaned up
"""
if keep_clone_dir is not None:
subrepo_path = Path(keep_clone_dir) / entry.name
if subrepo_path.exists():
raise RuntimeError(
f"Clone path already exists: {subrepo_path}. "
"Remove it or use a different --keep-clone-dir for a fresh clone."
)
subrepo_path.parent.mkdir(parents=True, exist_ok=True)
tmpdir = None
else:
tmpdir = tempfile.TemporaryDirectory()
subrepo_path = Path(tmpdir.name) / entry.name
try:
_clone_subrepo(entry.url, entry.branch, subrepo_path)
if dry_run:
patch_count = len(patch_paths)
logger.info(
f"[Dry-run] Would apply {patch_count} patch(es) to {entry.url} as {author_name} <{author_email}>"
)
return
_configure_git_user(subrepo_path)
# Apply each patch and create separate commits (skip empty patches)
committed = 0
for i, patch_path in enumerate(patch_paths, 1):
logger.debug(f"Applying patch {i}/{len(patch_paths)}: {patch_path.name}")
had_changes = _apply_patch(subrepo_path, patch_path)
if not had_changes:
continue
_stage_changes(subrepo_path)
original_commit_msg = _extract_commit_message_from_patch(patch_path)
commit_msg = _format_commit_message(
super_repo_url, super_repo_pr, merge_sha, original_commit_msg
)
_commit_changes(subrepo_path, commit_msg, author_name, author_email)
committed += 1
logger.debug(f"Committed patch {i}/{len(patch_paths)}")
if no_push:
logger.info(
f"[Mock run] Applied {committed} patch(es) and committed locally at {subrepo_path}; not pushed."
)
elif committed == 0:
logger.info(
f"No commits created for {entry.url} (all patches were empty); nothing to push."
)
else:
_set_authenticated_remote(subrepo_path, entry.url)
_push_changes(subrepo_path, entry.branch)
logger.info(
f"Applied {committed} patch(es), committed, and pushed to {entry.url} as {author_name} <{author_email}>"
)
except Exception:
# Preserve the clone directory on error so it can be inspected when --keep-clone-dir is used.
logger.exception(
"Error while applying patches to %s; clone directory preserved at %s for inspection.",
entry.url,
subrepo_path,
)
raise
finally:
if tmpdir is not None:
tmpdir.cleanup()
def main(argv: Optional[List[str]] = None) -> None:
"""Main function to apply patches to sub-repositories."""
args = parse_arguments(argv)
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
client = GitHubCLIClient()
config = load_repo_config(args.config)
subtrees = [line.strip() for line in args.subtrees.splitlines() if line.strip()]
relevant_subtrees = get_subtree_info(config, subtrees)
merge_sha = client.get_merge_commit(args.repo, args.pr)
if not merge_sha:
logger.error(f"Could not get merge commit for PR #{args.pr} in {args.repo}")
return
logger.debug(f"Merge commit for PR #{args.pr} in {args.repo}: {merge_sha}")
# Use merge commit's parents so the range is exactly "commits from this PR", not API base..merge
# (squash: merge^1..merge; merge commit: merge^1..merge^2)
base_sha, range_end = _get_patch_range(merge_sha)
logger.debug(f"Patch range for PR #{args.pr}: {base_sha}..{range_end}")
if args.keep_clone_dir:
args.no_push = True
# In dry-run do not write to keep_clone_dir (use temp clone only for logging)
keep_clone_path = (
Path(args.keep_clone_dir) if args.keep_clone_dir and not args.dry_run else None
)
if args.keep_clone_dir:
args.no_push = True
# In dry-run do not write to keep_clone_dir (use temp clone only for logging)
keep_clone_path = (
Path(args.keep_clone_dir) if args.keep_clone_dir and not args.dry_run else None
)
for entry in relevant_subtrees:
prefix = f"{entry.category}/{entry.name}/"
logger.debug(f"Processing subtree {prefix}")
if args.keep_patches_dir:
patch_dir = Path(args.keep_patches_dir) / f"{entry.category}-{entry.name}"
patch_dir.mkdir(parents=True, exist_ok=True)
for f in patch_dir.glob("*.patch"):
f.unlink()
else:
patch_dir = Path(tempfile.mkdtemp())
# generate_patch uses only the parent dir; path is a placeholder for patch_dir
patch_dir_anchor = patch_dir / f"{entry.name}.patch"
try:
patch_result = generate_patch(
prefix, patch_dir_anchor, base_sha, range_end, debug=args.debug
)
if patch_result is None:
logger.debug(f"No patches to apply for subtree {prefix}, skipping")
if args.keep_patches_dir and patch_dir.exists():
shutil.rmtree(patch_dir, ignore_errors=True)
continue
author_name, author_email = resolve_patch_author(client, args.repo, args.pr)
apply_patch_to_subrepo(
entry,
args.repo,
args.pr,
patch_result,
author_name,
author_email,
merge_sha,
dry_run=args.dry_run,
no_push=args.no_push,
keep_clone_dir=keep_clone_path,
)
finally:
if not args.keep_patches_dir:
shutil.rmtree(patch_dir, ignore_errors=True)
if __name__ == "__main__":
main()