diff --git a/tools/make_backports.py b/tools/make_backports.py index 5ad4276b1f3..8566d4c23ee 100755 --- a/tools/make_backports.py +++ b/tools/make_backports.py @@ -25,7 +25,7 @@ def run( return result -def fetch_needs_backport_pr_numbers() -> tuple[int, ...]: +def fetch_needs_backport_pr_numbers(args) -> tuple[int, ...]: """Use gh cli to collect the set of PRs that are labeled as needs_backport. Then cache them to disk. This is the implementation for --fetch-backport-prs. @@ -51,6 +51,10 @@ def get_needs_backport_pr_numbers() -> tuple[int, ...]: return tuple(int(line) for line in lines) +# +# Commit log parsing +# + # we use history_idx to sort by age. CommitInfo = namedtuple( "CommitInfo", ["pr_number", "shorthash", "shortlog", "history_idx"] @@ -62,9 +66,13 @@ class CommitHistory: commits: dict[int, CommitInfo] - def __init__(self): + @classmethod + def from_git(self): result = run(["git", "log", "--oneline", "main"], capture_output=True) lines = result.stdout.splitlines() + return CommitHistory(lines) + + def __init__(self, lines): commits = {} PR_NUMBER_RE = re.compile(r"\(#[0-9]+\)$") for history_idx, line in enumerate(lines): @@ -84,12 +92,14 @@ def lookup_pr(self, pr_number: int) -> CommitInfo: def get_commits() -> list[CommitInfo]: """Return the CommitInfo of the PRs we want to backport""" pr_numbers = get_needs_backport_pr_numbers() - commit_history = CommitHistory() + commit_history = CommitHistory.from_git() commits = [commit_history.lookup_pr(x) for x in pr_numbers] return sorted(commits, key=lambda c: -c.history_idx) +# # Changelog parsing +# @dataclass @@ -342,16 +352,50 @@ def remove_release_notes_from_unreleased_section( self.unreleased.delete_entry(idx) -def show_missing_changelogs() -> None: +# +# Main commands +# + + +def add_backport_pr(args): + pr_number_str = args.pr_number + run( + [ + "gh", + "pr", + "edit", + pr_number_str.removeprefix("#"), + "--add-label", + "needs backport", + ] + ) + fetch_needs_backport_pr_numbers(None) + + +def remove_needs_backport_labels(args) -> None: + for pr_number in get_needs_backport_pr_numbers(): + run(["gh", "pr", "edit", str(pr_number), "--remove-label", "needs backport"]) + + +def show_missing_changelogs(args) -> None: changelog = Changelog(CHANGELOG).parse() + changelog.unreleased.create_pr_index() commits = get_commits() - for commit in commits: - pr_number = commit.pr_number - if pr_number not in changelog.unreleased.pr_index: - print(pr_number, commit.shortlog) + missing_changelogs = [ + commit + for commit in commits + if commit.pr_number not in changelog.unreleased.pr_index + ] + for commit in missing_changelogs: + if args.web: + run(["gh", "pr", "view", "-w", str(commit.pr_number)]) + else: + print(commit.pr_number, commit.shorthash, commit.shortlog) -def make_changelog_branch(version: str) -> None: +def make_changelog_branch(args) -> None: + version = args.new_version + run(["git", "fetch", "upstream", "main:main"]) changelog = Changelog(CHANGELOG).parse() changelog.unreleased.create_pr_index() run(["git", "switch", "main"]) @@ -364,7 +408,10 @@ def make_changelog_branch(version: str) -> None: run(["git", "commit", "-m", f"Update changelog for v{version}"]) -def make_backport_branch(version: str) -> None: +def make_backport_branch(args) -> None: + version = args.new_version + run(["git", "fetch", "upstream", "main:main"]) + run(["git", "fetch", "upstream", "stable:stable"]) changelog = Changelog(CHANGELOG).parse() changelog.unreleased.create_pr_index() run(["git", "switch", "stable"]) @@ -372,63 +419,121 @@ def make_backport_branch(version: str) -> None: run(["git", "switch", "-C", f"backports-for-{version}-tmp"]) commits = get_commits() for n, cur_commit in enumerate(commits): - result = run(["git", "cherry-pick", cur_commit.shorthash], check=False) + result = run( + ["git", "-c", "core.editor=true", "cherry-pick", cur_commit.shorthash], + check=False, + capture_output=True, + ) + for line in result.stdout.splitlines(): + # We need to resolve submodule conflicts ourselves. We always pick + # the submodule version from the commit we are cherry-picking. + if not line.startswith("CONFLICT (submodule)"): + continue + path = line.partition("Merge conflict in ")[-1] + run(["git", "checkout", cur_commit.shorthash, "--", path]) changelog.set_patch_release_notes(version, commits[: n + 1]) changelog.write_text(include_unreleased=False) run(["git", "add", "docs/project/changelog.md"]) if result.returncode == 0: + print("cherry-pick succeeded first try", cur_commit.shortlog) run(["git", "commit", "--amend"]) else: - run(["git", "cherry-pick", "--continue"]) + print("cherry-pick attempting continue", cur_commit.shortlog) + run(["git", "cherry-pick", "--continue", "--no-edit"]) commits = get_commits() -def remove_needs_backport_labels() -> None: - for pr_number in get_needs_backport_pr_numbers(): - run(["gh", "pr", "edit", str(pr_number), "--remove-label", "needs backport"]) +def open_release_prs(args): + version = args.new_version + INSERT_ACTUAL_DATE = "- [ ] Insert the actual date in the changelog\n" + MERGE_DONT_SQUASH = "- [] Merge, don't squash" + BACKPORTS_BRANCH = f"backports-for-{version}-tmp" + CHANGELOG_BRANCH = f"changelog-for-{version}-tmp" + + run(["git", "switch", BACKPORTS_BRANCH]) + run( + [ + "gh", + "pr", + "create", + "--base", + "stable", + "--title", + f"Backports for v{version}", + "--body", + INSERT_ACTUAL_DATE + MERGE_DONT_SQUASH, + "--web", + ] + ) + + run(["git", "switch", CHANGELOG_BRANCH]) + run( + [ + "gh", + "pr", + "create", + "--base", + "main", + "--title", + f"Changelog for v{version}", + "--body", + INSERT_ACTUAL_DATE, + "--web", + ] + ) def parse_args(): parser = argparse.ArgumentParser("Apply backports") - parser.add_argument("new_version") - parser.add_argument( - "--fetch-backport-prs", - action="store_true", + parser.set_defaults(func=lambda args: parser.print_help()) + subparsers = parser.add_subparsers() + + add_backport_parser = subparsers.add_parser( + "add-backport-pr", help="Add the needs-backport label to a PR" + ) + add_backport_parser.add_argument("pr_number") + add_backport_parser.set_defaults(func=add_backport_pr) + + fetch_backports_parser = subparsers.add_parser( + "fetch-backports", help="Fetch the list of PRs with the 'needs backport' label and cache to disk. Must be run first.", ) - parser.add_argument( - "--missing-changelogs", - action="store_true", + fetch_backports_parser.set_defaults(func=fetch_needs_backport_pr_numbers) + + missing_changelogs_parser = subparsers.add_parser( + "missing-changelogs", help="List the PRs labeled as 'needs backport' that don't have a changelog", ) - parser.add_argument( - "--changelog-branch", - action="store_true", - help="Make changelog-for-version branch", + missing_changelogs_parser.add_argument( + "-w", "--web", action="store_true", help="Open missing changelog prs in browser" + ) + missing_changelogs_parser.set_defaults(func=show_missing_changelogs) + + changelog_branch_parse = subparsers.add_parser( + "changelog-branch", help="Make changelog-for-version branch" ) - parser.add_argument( - "--backport-branch", - action="store_true", - help="Make backports-for-version branch", + changelog_branch_parse.add_argument("new_version") + changelog_branch_parse.set_defaults(func=make_changelog_branch) + + backport_branch_parse = subparsers.add_parser( + "backport-branch", help="Make backports-for-version branch" ) + backport_branch_parse.add_argument("new_version") + backport_branch_parse.set_defaults(func=make_backport_branch) + + open_release_prs_parse = subparsers.add_parser( + "open-release-prs", help="Open PRs for the backports and changelog branches" + ) + open_release_prs_parse.add_argument("new_version") + open_release_prs_parse.set_defaults(func=open_release_prs) + return parser.parse_args() def main(): args = parse_args() - if args.fetch_backport_prs: - fetch_needs_backport_pr_numbers() - return - if args.missing_changelogs: - show_missing_changelogs() - return - if args.changelog_branch: - make_changelog_branch(args.new_version) - return - if args.backport_branch: - make_backport_branch(args.new_version) - return + args.func(args) if __name__ == "__main__":