281 lines
8.4 KiB
Python
281 lines
8.4 KiB
Python
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from collections import defaultdict
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, NamedTuple
|
|
|
|
import requests
|
|
from packaging.version import Version
|
|
|
|
|
|
def get_header_for_version(version: str) -> str:
|
|
return "## {} ({})".format(version, datetime.now().strftime("%Y-%m-%d"))
|
|
|
|
|
|
def extract_pr_num_from_git_log_entry(git_log_entry: str) -> int | None:
|
|
m = re.search(r"\(#(\d+)\)$", git_log_entry)
|
|
return int(m.group(1)) if m else None
|
|
|
|
|
|
def format_label(label: str) -> str:
|
|
key = label.split("/", 1)[-1]
|
|
return {
|
|
"model-registry": "Model Registry",
|
|
"uiux": "UI",
|
|
}.get(key, key.capitalize())
|
|
|
|
|
|
class PullRequest(NamedTuple):
|
|
title: str
|
|
number: int
|
|
author: str
|
|
labels: list[str]
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"https://github.com/mlflow/mlflow/pull/{self.number}"
|
|
|
|
@property
|
|
def release_note_labels(self) -> list[str]:
|
|
return [l for l in self.labels if l.startswith("rn/")]
|
|
|
|
def __str__(self) -> str:
|
|
areas = " / ".join(
|
|
sorted(
|
|
map(
|
|
format_label,
|
|
filter(lambda l: l.split("/")[0] in ("area", "language"), self.labels),
|
|
)
|
|
)
|
|
)
|
|
return f"[{areas}] {self.title} (#{self.number}, @{self.author})"
|
|
|
|
def __repr__(self) -> str:
|
|
return str(self)
|
|
|
|
|
|
class Section(NamedTuple):
|
|
title: str
|
|
items: list[Any]
|
|
|
|
def __str__(self) -> str:
|
|
if not self.items:
|
|
return ""
|
|
return "\n\n".join([
|
|
self.title,
|
|
"\n".join(f"- {item}" for item in self.items),
|
|
])
|
|
|
|
|
|
def is_shallow() -> bool:
|
|
return (
|
|
subprocess.check_output(
|
|
[
|
|
"git",
|
|
"rev-parse",
|
|
"--is-shallow-repository",
|
|
],
|
|
text=True,
|
|
).strip()
|
|
== "true"
|
|
)
|
|
|
|
|
|
def batch_fetch_prs_graphql(pr_numbers: list[int]) -> list[PullRequest]:
|
|
"""
|
|
Batch fetch PR data using GitHub GraphQL API.
|
|
"""
|
|
if not pr_numbers:
|
|
return []
|
|
|
|
# GitHub GraphQL has query size limits, so batch in chunks
|
|
MAX_PRS_PER_QUERY = 50 # Conservative limit to avoid query size issues
|
|
all_prs: list[PullRequest] = []
|
|
|
|
for i in range(0, len(pr_numbers), MAX_PRS_PER_QUERY):
|
|
chunk = pr_numbers[i : i + MAX_PRS_PER_QUERY]
|
|
chunk_prs = _fetch_pr_chunk_graphql(chunk)
|
|
all_prs.extend(chunk_prs)
|
|
|
|
return all_prs
|
|
|
|
|
|
def _fetch_pr_chunk_graphql(pr_numbers: list[int]) -> list[PullRequest]:
|
|
"""
|
|
Fetch a chunk of PRs using GraphQL.
|
|
"""
|
|
# Build GraphQL query with aliases for each PR
|
|
query_parts = [
|
|
"query($owner: String!, $repo: String!) {",
|
|
" repository(owner: $owner, name: $repo) {",
|
|
]
|
|
|
|
for i, pr_num in enumerate(pr_numbers):
|
|
query_parts.append(f"""
|
|
pr{i}: pullRequest(number: {pr_num}) {{
|
|
number
|
|
title
|
|
author {{
|
|
login
|
|
}}
|
|
labels(first: 100) {{
|
|
nodes {{
|
|
name
|
|
}}
|
|
}}
|
|
}}""")
|
|
|
|
query_parts.extend([" }", "}"])
|
|
query = "\n".join(query_parts)
|
|
|
|
# Headers with authentication
|
|
headers = {"Content-Type": "application/json"}
|
|
if token := os.environ.get("GH_TOKEN"):
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
print(f"Batch fetching {len(pr_numbers)} PRs with GraphQL...")
|
|
resp = requests.post(
|
|
"https://api.github.com/graphql",
|
|
json={
|
|
"query": query,
|
|
"variables": {"owner": "mlflow", "repo": "mlflow"},
|
|
},
|
|
headers=headers,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if "errors" in data:
|
|
raise Exception(f"GraphQL errors: {data['errors']}")
|
|
|
|
# Extract PR data from response and create PullRequest objects
|
|
repository_data = data["data"]["repository"]
|
|
prs = []
|
|
for i, pr_num in enumerate(pr_numbers):
|
|
pr_info = repository_data.get(f"pr{i}")
|
|
if pr_info and pr_info.get("author"):
|
|
prs.append(
|
|
PullRequest(
|
|
title=pr_info["title"],
|
|
number=pr_info["number"],
|
|
author=pr_info["author"]["login"],
|
|
labels=[label["name"] for label in pr_info["labels"]["nodes"]],
|
|
)
|
|
)
|
|
else:
|
|
print(f"Warning: Could not fetch data for PR #{pr_num}")
|
|
|
|
return prs
|
|
|
|
|
|
def main(prev_version: str, release_version: str, remote: str) -> None:
|
|
if is_shallow():
|
|
print("Unshallowing repository to ensure `git log` works correctly")
|
|
subprocess.check_call(["git", "fetch", "--unshallow"])
|
|
print("Modifying .git/config to fetch remote branches")
|
|
subprocess.check_call([
|
|
"git",
|
|
"config",
|
|
"remote.origin.fetch",
|
|
"+refs/heads/*:refs/remotes/origin/*",
|
|
])
|
|
release_tag = f"v{prev_version}"
|
|
ver = Version(release_version)
|
|
branch = f"branch-{ver.major}.{ver.minor}"
|
|
subprocess.check_call(["git", "fetch", remote, "tag", release_tag])
|
|
subprocess.check_call(["git", "fetch", remote, branch])
|
|
git_log_output = subprocess.check_output(
|
|
[
|
|
"git",
|
|
"log",
|
|
"--left-right",
|
|
"--graph",
|
|
"--cherry-pick",
|
|
"--pretty=format:%s",
|
|
f"tags/{release_tag}...{remote}/{branch}",
|
|
],
|
|
text=True,
|
|
)
|
|
logs = [l[2:] for l in git_log_output.splitlines() if l.startswith("> ")]
|
|
|
|
# Extract all PR numbers first
|
|
pr_numbers = [pr_num for log in logs if (pr_num := extract_pr_num_from_git_log_entry(log))]
|
|
|
|
prs = batch_fetch_prs_graphql(pr_numbers)
|
|
label_to_prs = defaultdict(list)
|
|
author_to_prs = defaultdict(list)
|
|
unlabelled_prs = []
|
|
for pr in prs:
|
|
if pr.author == "mlflow-app":
|
|
continue
|
|
|
|
if len(pr.release_note_labels) == 0:
|
|
unlabelled_prs.append(pr)
|
|
|
|
for label in pr.release_note_labels:
|
|
if label == "rn/none":
|
|
author_to_prs[pr.author].append(pr)
|
|
else:
|
|
label_to_prs[label].append(pr)
|
|
|
|
assert len(unlabelled_prs) == 0, "The following PRs need to be categorized:\n" + "\n".join(
|
|
f"- {pr.url}" for pr in unlabelled_prs
|
|
)
|
|
|
|
unknown_labels = set(label_to_prs.keys()) - {
|
|
"rn/highlight",
|
|
"rn/feature",
|
|
"rn/breaking-change",
|
|
"rn/bug-fix",
|
|
"rn/documentation",
|
|
"rn/none",
|
|
}
|
|
assert len(unknown_labels) == 0, f"Unknown labels: {unknown_labels}"
|
|
|
|
breaking_changes = Section("Breaking changes:", label_to_prs.get("rn/breaking-change", []))
|
|
highlights = Section("Major new features:", label_to_prs.get("rn/highlight", []))
|
|
features = Section("Features:", label_to_prs.get("rn/feature", []))
|
|
bug_fixes = Section("Bug fixes:", label_to_prs.get("rn/bug-fix", []))
|
|
doc_updates = Section("Documentation updates:", label_to_prs.get("rn/documentation", []))
|
|
small_updates_items = [
|
|
", ".join([f"#{pr.number}" for pr in prs] + [f"@{author}"])
|
|
for author, prs in author_to_prs.items()
|
|
]
|
|
small_updates = "Small bug fixes and documentation updates:\n\n" + "; ".join(
|
|
small_updates_items
|
|
)
|
|
sections = [
|
|
s
|
|
for sec in [
|
|
get_header_for_version(release_version),
|
|
f"MLflow {release_version} includes several major features and improvements",
|
|
breaking_changes,
|
|
highlights,
|
|
features,
|
|
bug_fixes,
|
|
doc_updates,
|
|
small_updates,
|
|
]
|
|
if (s := str(sec).strip())
|
|
]
|
|
new_changelog = "\n\n".join(sections)
|
|
changelog_header = "# CHANGELOG"
|
|
changelog = Path("CHANGELOG.md")
|
|
old_changelog = changelog.read_text().replace(f"{changelog_header}\n\n", "", 1)
|
|
new_changelog = "\n\n".join([
|
|
changelog_header,
|
|
new_changelog,
|
|
old_changelog,
|
|
])
|
|
changelog.write_text(new_changelog)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Update CHANGELOG.md")
|
|
parser.add_argument("--prev-version", required=True, help="Previous version")
|
|
parser.add_argument("--release-version", required=True, help="MLflow version to release")
|
|
parser.add_argument("--remote", default="origin", help="Git remote to use (default: origin)")
|
|
args = parser.parse_args()
|
|
main(args.prev_version, args.release_version, args.remote)
|