291 lines
9.6 KiB
Python
291 lines
9.6 KiB
Python
import argparse
|
|
import concurrent.futures
|
|
import itertools
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import requests
|
|
from packaging.version import Version
|
|
|
|
MAX_COMMITS_PER_SCRIPT = 90
|
|
|
|
|
|
def chunk_list(lst: list[str], size: int) -> list[list[str]]:
|
|
return [lst[i : i + size] for i in range(0, len(lst), size)]
|
|
|
|
|
|
def get_token() -> str | None:
|
|
if token := os.environ.get("GH_TOKEN"):
|
|
return token
|
|
try:
|
|
token = subprocess.check_output(
|
|
["gh", "auth", "token"], text=True, stderr=subprocess.DEVNULL
|
|
).strip()
|
|
if token:
|
|
return token
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_headers() -> dict[str, str]:
|
|
if token := get_token():
|
|
return {"Authorization": f"token {token}"}
|
|
return {}
|
|
|
|
|
|
def validate_version(version: str) -> None:
|
|
"""
|
|
Validate that the version has a micro version component.
|
|
Raises ValueError if the version is invalid.
|
|
"""
|
|
parsed_version = Version(version)
|
|
if len(parsed_version.release) != 3:
|
|
raise ValueError(
|
|
f"Invalid version: '{version}'. "
|
|
"Version must be in the format <major>.<minor>.<micro> (e.g., '2.10.0')"
|
|
)
|
|
|
|
|
|
def get_release_branch(version: str) -> str:
|
|
major_minor_version = ".".join(version.split(".")[:2])
|
|
return f"branch-{major_minor_version}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Commit:
|
|
sha: str
|
|
pr_num: int
|
|
date: str
|
|
|
|
|
|
def get_commit_count(branch: str, since: str) -> int:
|
|
"""
|
|
Get the total count of commits in the branch since the given date using GraphQL API.
|
|
"""
|
|
query = """
|
|
query($branch: String!, $since: GitTimestamp!) {
|
|
repository(owner: "mlflow", name: "mlflow") {
|
|
ref(qualifiedName: $branch) {
|
|
target {
|
|
... on Commit {
|
|
history(since: $since) {
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
response = requests.post(
|
|
"https://api.github.com/graphql",
|
|
json={"query": query, "variables": {"branch": branch, "since": since}},
|
|
headers=get_headers(),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
ref = data["data"]["repository"]["ref"]
|
|
if ref is None:
|
|
raise ValueError(f"Branch '{branch}' not found")
|
|
total_count: int = ref["target"]["history"]["totalCount"]
|
|
return total_count
|
|
|
|
|
|
def get_commits(branch: str) -> list[Commit]:
|
|
"""
|
|
Get the commits in the release branch via GitHub API (last 90 days).
|
|
Returns commits sorted by date (oldest first).
|
|
"""
|
|
per_page = 100
|
|
pr_rgx = re.compile(r".+\s+\(#(\d+)\)$")
|
|
since = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
|
|
|
|
# Get total commit count first
|
|
total_count = get_commit_count(branch, since)
|
|
if total_count == 0:
|
|
print(f"No commits found in {branch} since {since}")
|
|
return []
|
|
|
|
total_pages = (total_count + per_page - 1) // per_page
|
|
print(f"Total commits: {total_count}, fetching {total_pages} page(s)...")
|
|
|
|
def fetch_page(page: int) -> list[Commit]:
|
|
print(f"Fetching page {page}/{total_pages}...")
|
|
params: dict[str, str | int] = {
|
|
"sha": branch,
|
|
"per_page": per_page,
|
|
"page": page,
|
|
"since": since,
|
|
}
|
|
response = requests.get(
|
|
"https://api.github.com/repos/mlflow/mlflow/commits",
|
|
params=params,
|
|
headers=get_headers(),
|
|
)
|
|
response.raise_for_status()
|
|
commits = []
|
|
for item in response.json():
|
|
msg = item["commit"]["message"].split("\n")[0]
|
|
if m := pr_rgx.search(msg):
|
|
# Use committer date (not author date) because cherry-picked commits
|
|
# retain the original author date but get a new committer date.
|
|
date = item["commit"]["committer"]["date"]
|
|
commits.append(Commit(sha=item["sha"], pr_num=int(m.group(1)), date=date))
|
|
return commits
|
|
|
|
# Fetch all pages in parallel. executor.map preserves order.
|
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
results = executor.map(fetch_page, range(1, total_pages + 1))
|
|
|
|
return sorted(itertools.chain.from_iterable(results), key=lambda c: c.date)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PR:
|
|
pr_num: int
|
|
merged: bool
|
|
|
|
|
|
def is_closed(pr: dict[str, Any]) -> bool:
|
|
return pr["state"] == "closed" and pr["pull_request"]["merged_at"] is None
|
|
|
|
|
|
def fetch_patch_prs(version: str) -> dict[int, bool]:
|
|
"""
|
|
Fetch PRs labeled with `v{version}` from the MLflow repository.
|
|
"""
|
|
label = f"v{version}"
|
|
per_page = 100
|
|
page = 1
|
|
pulls: list[dict[str, Any]] = []
|
|
while True:
|
|
response = requests.get(
|
|
f'https://api.github.com/search/issues?q=is:pr+repo:mlflow/mlflow+label:"{label}"&per_page={per_page}&page={page}',
|
|
headers=get_headers(),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
# Exclude closed PRs that are not merged
|
|
pulls.extend(pr for pr in data["items"] if not is_closed(pr))
|
|
if len(data["items"]) < per_page:
|
|
break
|
|
page += 1
|
|
|
|
return {pr["number"]: pr["pull_request"].get("merged_at") is not None for pr in pulls}
|
|
|
|
|
|
def main(version: str, dry_run: bool) -> None:
|
|
validate_version(version)
|
|
release_branch = get_release_branch(version)
|
|
commits = get_commits(release_branch)
|
|
patch_prs = fetch_patch_prs(version)
|
|
if not_cherry_picked := set(patch_prs) - {c.pr_num for c in commits}:
|
|
print(f"The following patch PRs are not cherry-picked to {release_branch}:")
|
|
for idx, pr_num in enumerate(sorted(not_cherry_picked)):
|
|
merged = patch_prs[pr_num]
|
|
url = f"https://github.com/mlflow/mlflow/pull/{pr_num} (merged: {merged})"
|
|
line = f" {idx + 1}. {url}"
|
|
if not merged:
|
|
line = f"\033[91m{line}\033[0m" # Red color using ANSI escape codes
|
|
print(line)
|
|
|
|
master_commits = get_commits("master")
|
|
cherry_picks = [c.sha for c in master_commits if c.pr_num in not_cherry_picked]
|
|
|
|
# Split into chunks if needed
|
|
chunks = chunk_list(cherry_picks, MAX_COMMITS_PER_SCRIPT)
|
|
|
|
# Print warning if splitting
|
|
if len(chunks) > 1:
|
|
print(
|
|
f"\n⚠️ WARNING: {len(cherry_picks)} commits will be split into "
|
|
f"{len(chunks)} scripts."
|
|
)
|
|
print("Create one PR per script and merge them sequentially:")
|
|
print(" file PR 1 → merge PR 1 → pull release branch → file PR 2 → merge PR 2 → ...")
|
|
print("This is required to stay under GitHub's 100-commit rebase merge limit.\n")
|
|
|
|
print("\n# Steps to cherry-pick the patch PRs:")
|
|
print(
|
|
f"1. Make sure your local master and {release_branch} branches are synced with "
|
|
"upstream."
|
|
)
|
|
print(f"2. Cut a new branch from {release_branch} (e.g. {release_branch}-cherry-picks).")
|
|
|
|
# Generate script(s)
|
|
tmp_dir = Path(tempfile.gettempdir())
|
|
script_paths: list[tuple[Path, int]] = []
|
|
for i, chunk in enumerate(chunks, 1):
|
|
if len(chunks) == 1:
|
|
script_path = tmp_dir / "cherry-pick.sh"
|
|
else:
|
|
script_path = tmp_dir / f"cherry-pick-{i}.sh"
|
|
|
|
script_content = f"""\
|
|
#!/usr/bin/env bash
|
|
# Cherry-picks for v{version} -> {release_branch}
|
|
# Generated: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")}
|
|
# Commits: {len(chunk)} of {len(cherry_picks)} total
|
|
#
|
|
# If conflicts occur, resolve them and run:
|
|
# git cherry-pick --continue
|
|
|
|
set -euo pipefail
|
|
|
|
# Guard: Prevent running on master branch
|
|
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
|
if [[ "$current_branch" == "master" ]]; then
|
|
echo "ERROR: This script must not be run on the master branch."
|
|
echo "Please checkout a release branch (e.g., {release_branch}) or a branch derived from it."
|
|
exit 1
|
|
fi
|
|
|
|
git cherry-pick {" ".join(chunk)}
|
|
"""
|
|
script_path.write_text(script_content)
|
|
script_path.chmod(0o755)
|
|
script_paths.append((script_path, len(chunk)))
|
|
|
|
if len(chunks) == 1:
|
|
print("3. Run the cherry-pick script on the new branch:\n")
|
|
print(f"Cherry-pick script written to: {script_paths[0][0]}")
|
|
print(f"\n4. File a PR against {release_branch}.")
|
|
else:
|
|
print("3. For each script (in order):")
|
|
print(f" a. Create a new branch from {release_branch}")
|
|
print(" b. Run the script")
|
|
print(f" c. File a PR against {release_branch}")
|
|
print(f" d. After merge, pull {release_branch} from remote before the next script\n")
|
|
print(" Scripts:")
|
|
for script_path, commit_count in script_paths:
|
|
print(f" {script_path} ({commit_count} commits)")
|
|
|
|
sys.exit(0 if dry_run else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--version", required=True, help="The version to release")
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
default=os.environ.get("DRY_RUN", "true").lower() == "true",
|
|
help="Dry run mode (default: True, can be set via DRY_RUN env var)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-dry-run",
|
|
action="store_false",
|
|
dest="dry_run",
|
|
help="Disable dry run mode",
|
|
)
|
|
args = parser.parse_args()
|
|
main(args.version, args.dry_run)
|