From 7911b96bd54a44eb0dccc582423dc1b8b2ac2700 Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:01:32 +0800 Subject: [PATCH 1/8] [INFRA] Normalize merge script confirmation prompts --- dev/merge_kyuubi_pr.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 5894ebdf212..f989e9cfe4e 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -67,19 +67,19 @@ def fail(msg): def run_cmd(cmd): print(cmd) if isinstance(cmd, list): - return subprocess.check_output(cmd).decode('utf-8') + return subprocess.check_output(cmd).decode("utf-8") else: - return subprocess.check_output(cmd.split(" ")).decode('utf-8') + return subprocess.check_output(cmd.split(" ")).decode("utf-8") def continue_maybe(prompt): - result = input("\n%s (y/n): " % prompt) + result = input("\n%s (y/N): " % prompt) if result.lower() != "y": fail("Okay, exiting") def clean_up(): - if 'original_head' in globals(): + if "original_head" in globals(): print("Restoring head pointer to %s" % original_head) run_cmd("git checkout %s" % original_head) From b488ca3e2a2d8866188b694911ef35c9bcc91f98 Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:03:03 +0800 Subject: [PATCH 2/8] [INFRA] Suggest next backport branch in merge script --- dev/merge_kyuubi_pr.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index f989e9cfe4e..12e118209db 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -89,6 +89,13 @@ def clean_up(): print("Deleting local branch %s" % branch) run_cmd("git branch -D %s" % branch) + +def default_pick_branch(branch_names, already_picked): + """Return the newest release branch that has not received the change.""" + remaining = [branch for branch in branch_names if branch not in already_picked] + return remaining[0] if remaining else None + + def fix_title(text, num): if (re.search(r'^\[KYUUBI\s#[0-9]{3,6}\].*', text)): return text @@ -214,7 +221,7 @@ def cherry_pick(pr_num, merge_hash, default_branch): print("Pull request #%s picked into %s!" % (pr_num, pick_ref)) print("Pick hash: %s" % pick_hash) - return pick_ref + return pick_ref, pick_hash def get_current_ref(): ref = run_cmd("git rev-parse --abbrev-ref HEAD").strip() @@ -236,7 +243,7 @@ def main(): # Assumes branch names can be sorted lexicographically def sort_by_version(branch_name): return tuple(map(int, branch_name.split('-')[1].split('.'))) - latest_branch = sorted(branch_names, key=sort_by_version, reverse=True)[0] + branch_names = sorted(branch_names, key=sort_by_version, reverse=True) pr_num = input("Which pull request would you like to merge? (e.g. 34): ") pr = get_json("%s/pulls/%s" % (GITHUB_API_BASE, pr_num)) @@ -269,7 +276,16 @@ def sort_by_version(branch_name): fail("Couldn't find any merge commit for #%s, you may need to update HEAD." % pr_num) print("Found commit %s:\n%s" % (merge_hash, message)) - cherry_pick(pr_num, merge_hash, latest_branch) + picked_refs = [target_ref] + while True: + default_branch = default_pick_branch(branch_names, tuple(picked_refs)) + if default_branch is None: + print("Every known release branch already contains #%s; nothing to pick." % pr_num) + break + picked_refs = picked_refs + [cherry_pick(pr_num, merge_hash, default_branch)[0]] + prompt = "Would you like to pick %s into another branch?" % merge_hash + if input("\n%s (y/N): " % prompt).lower() != "y": + break sys.exit(0) if not bool(pr["mergeable"]): @@ -298,8 +314,12 @@ def sort_by_version(branch_name): merge_hash = merge_pr(pr_num, target_ref, title, body, pr_repo_desc) pick_prompt = "Would you like to pick %s into another branch?" % merge_hash - while input("\n%s (y/n): " % pick_prompt).lower() == "y": - merged_refs = merged_refs + [cherry_pick(pr_num, merge_hash, latest_branch)] + while input("\n%s (y/N): " % pick_prompt).lower() == "y": + default_branch = default_pick_branch(branch_names, tuple(merged_refs)) + if default_branch is None: + print("Every known release branch already contains #%s; nothing to pick." % pr_num) + break + merged_refs = merged_refs + [cherry_pick(pr_num, merge_hash, default_branch)[0]] if __name__ == "__main__": import doctest From aacade02c02b0d7a1a06b85794dd9454b4f5bb5b Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:04:31 +0800 Subject: [PATCH 3/8] [INFRA] Post merge summaries on pull requests --- dev/merge_kyuubi_pr.py | 196 +++++++++++++++++++++++++++++++---------- 1 file changed, 148 insertions(+), 48 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 12e118209db..88f2468b0b6 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -39,6 +39,7 @@ PUSH_REMOTE_NAME = os.environ.get("PUSH_REMOTE_NAME", "apache") GITHUB_OAUTH_KEY = os.environ.get("GITHUB_OAUTH_KEY") GITHUB_API_BASE = "https://api.github.com/repos/apache/kyuubi" +GITHUB_COMMIT_BASE = "https://github.com/apache/kyuubi/commit" BRANCH_PREFIX = "PR_TOOL" @@ -90,29 +91,78 @@ def clean_up(): run_cmd("git branch -D %s" % branch) +def comment_pr(pr_num, body): + url = "%s/issues/%s/comments" % (GITHUB_API_BASE, pr_num) + data = json.dumps({"body": body}).encode("utf-8") + request = Request(url, data=data, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Accept", "application/vnd.github+json") + if GITHUB_OAUTH_KEY: + request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY) + try: + return json.load(urlopen(request)) + except HTTPError as e: + print("Failed to comment on PR #%s: HTTP %s %s" % (pr_num, e.code, e.reason)) + return None + + +def post_merge_comment(pr_num, merged_commits): + """Post a comment recording every branch the change landed on.""" + if not merged_commits: + return + + lines = [ + "- merged into %s %s/%s" % (ref, GITHUB_COMMIT_BASE, commit_hash) + for ref, commit_hash in merged_commits + ] + summary = "**Merge Summary:**\n" + "\n".join(lines) + attribution = "*Posted by `merge_kyuubi_pr.py`*" + body = "%s\n\n%s" % (summary, attribution) + print( + "\nPosting merge comment on PR #%s:\n\n%s\n%s" % (pr_num, summary, attribution) + ) + if not GITHUB_OAUTH_KEY: + print("GITHUB_OAUTH_KEY is not set; skipping the merge comment.") + return + comment_pr(pr_num, body) + + def default_pick_branch(branch_names, already_picked): - """Return the newest release branch that has not received the change.""" + """Return the newest release branch that has not received the change. + + >>> default_pick_branch(["branch-1.12", "branch-1.11"], ("master",)) + 'branch-1.12' + >>> default_pick_branch(["branch-1.12", "branch-1.11"], ("master", "branch-1.12")) + 'branch-1.11' + >>> default_pick_branch(["branch-1.12"], ("master", "branch-1.12")) is None + True + """ remaining = [branch for branch in branch_names if branch not in already_picked] return remaining[0] if remaining else None def fix_title(text, num): - if (re.search(r'^\[KYUUBI\s#[0-9]{3,6}\].*', text)): + if re.search(r"^\[KYUUBI\s#[0-9]{3,6}\].*", text): return text - return '[KYUUBI #%s] %s' % (num, text) + return "[KYUUBI #%s] %s" % (num, text) + # merge the requested PR and return the merge hash def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): pr_branch_name = "%s_MERGE_PR_%s" % (BRANCH_PREFIX, pr_num) - target_branch_name = "%s_MERGE_PR_%s_%s" % (BRANCH_PREFIX, pr_num, target_ref.upper()) + target_branch_name = "%s_MERGE_PR_%s_%s" % ( + BRANCH_PREFIX, + pr_num, + target_ref.upper(), + ) run_cmd("git fetch %s pull/%s/head:%s" % (PR_REMOTE_NAME, pr_num, pr_branch_name)) run_cmd("git fetch %s %s:%s" % (PUSH_REMOTE_NAME, target_ref, target_branch_name)) run_cmd("git checkout %s" % target_branch_name) had_conflicts = False try: - run_cmd(['git', 'merge', pr_branch_name, '--squash']) + run_cmd(["git", "merge", pr_branch_name, "--squash"]) except Exception as e: msg = "Error merging: %s\nWould you like to manually fix-up this merge?" % e continue_maybe(msg) @@ -166,23 +216,29 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): authors = "Authored-by:" if len(distinct_authors) == 1 else "Lead-authored-by:" authors += " %s" % (distinct_authors.pop(0)) if len(distinct_authors) > 0: - authors += "\n" + "\n".join(["Co-authored-by: %s" % a for a in distinct_authors]) + authors += "\n" + "\n".join( + ["Co-authored-by: %s" % a for a in distinct_authors] + ) authors += "\n" + "Signed-off-by: %s <%s>" % (committer_name, committer_email) merge_message_flags += ["-m", authors] - run_cmd(['git', 'commit', '--author="%s"' % primary_author] + merge_message_flags) + run_cmd(["git", "commit", '--author="%s"' % primary_author] + merge_message_flags) - continue_maybe("Merge complete (local ref %s). Push to %s?" % ( - target_branch_name, PUSH_REMOTE_NAME)) + continue_maybe( + "Merge complete (local ref %s). Push to %s?" + % (target_branch_name, PUSH_REMOTE_NAME) + ) try: - run_cmd('git push %s %s:%s' % (PUSH_REMOTE_NAME, target_branch_name, target_ref)) + run_cmd( + "git push %s %s:%s" % (PUSH_REMOTE_NAME, target_branch_name, target_ref) + ) except Exception as e: clean_up() fail("Exception while pushing: %s" % e) - merge_hash = run_cmd("git rev-parse %s" % target_branch_name)[:8] + merge_hash = run_cmd("git rev-parse %s" % target_branch_name).strip() clean_up() print("Pull request #%s merged!" % pr_num) print("Merge hash: %s" % merge_hash) @@ -202,30 +258,36 @@ def cherry_pick(pr_num, merge_hash, default_branch): try: run_cmd("git cherry-pick -sx %s" % merge_hash) except Exception as e: - msg = "Error cherry-picking: %s\nWould you like to manually fix-up this merge?" % e + msg = ( + "Error cherry-picking: %s\nWould you like to manually fix-up this merge?" + % e + ) continue_maybe(msg) msg = "Okay, please fix any conflicts and finish the cherry-pick. Finished?" continue_maybe(msg) - continue_maybe("Pick complete (local ref %s). Push to %s?" % ( - pick_branch_name, PUSH_REMOTE_NAME)) + continue_maybe( + "Pick complete (local ref %s). Push to %s?" + % (pick_branch_name, PUSH_REMOTE_NAME) + ) try: - run_cmd('git push %s %s:%s' % (PUSH_REMOTE_NAME, pick_branch_name, pick_ref)) + run_cmd("git push %s %s:%s" % (PUSH_REMOTE_NAME, pick_branch_name, pick_ref)) except Exception as e: clean_up() fail("Exception while pushing: %s" % e) - pick_hash = run_cmd("git rev-parse %s" % pick_branch_name)[:8] + pick_hash = run_cmd("git rev-parse %s" % pick_branch_name).strip() clean_up() print("Pull request #%s picked into %s!" % (pr_num, pick_ref)) print("Pick hash: %s" % pick_hash) return pick_ref, pick_hash + def get_current_ref(): ref = run_cmd("git rev-parse --abbrev-ref HEAD").strip() - if ref == 'HEAD': + if ref == "HEAD": # The current ref is a detached HEAD, so grab its SHA. return run_cmd("git rev-parse HEAD").strip() else: @@ -239,10 +301,13 @@ def main(): original_head = get_current_ref() branches = get_json("%s/branches" % GITHUB_API_BASE) - branch_names = list(filter(lambda x: x.startswith("branch-"), [x['name'] for x in branches])) - # Assumes branch names can be sorted lexicographically + branch_names = list( + filter(lambda x: x.startswith("branch-"), [x["name"] for x in branches]) + ) + # Sort release branches numerically, newest first. def sort_by_version(branch_name): - return tuple(map(int, branch_name.split('-')[1].split('.'))) + return tuple(map(int, branch_name.split("-")[1].split("."))) + branch_names = sorted(branch_names, key=sort_by_version, reverse=True) pr_num = input("Which pull request would you like to merge? (e.g. 34): ") @@ -262,42 +327,66 @@ def sort_by_version(branch_name): # Merged pull requests don't appear as merged in the GitHub API; # Instead, they're closed by asfgit. - merge_commits = \ - [e for e in pr_events if e["event"] == "closed" and e["commit_id"]] + merge_commits = [e for e in pr_events if e["event"] == "closed" and e["commit_id"]] if merge_commits: merge_hash = merge_commits[0]["commit_id"] - message = get_json("%s/commits/%s" % (GITHUB_API_BASE, merge_hash))["commit"]["message"] - - print("Pull request %s has already been merged, assuming you want to backport" % pr_num) - commit_is_downloaded = run_cmd(['git', 'rev-parse', '--quiet', '--verify', - "%s^{commit}" % merge_hash]).strip() != "" + message = get_json("%s/commits/%s" % (GITHUB_API_BASE, merge_hash))["commit"][ + "message" + ] + + print( + "Pull request %s has already been merged, assuming you want to backport" + % pr_num + ) + commit_is_downloaded = ( + run_cmd( + ["git", "rev-parse", "--quiet", "--verify", "%s^{commit}" % merge_hash] + ).strip() + != "" + ) if not commit_is_downloaded: - fail("Couldn't find any merge commit for #%s, you may need to update HEAD." % pr_num) + fail( + "Couldn't find any merge commit for #%s, you may need to update HEAD." + % pr_num + ) print("Found commit %s:\n%s" % (merge_hash, message)) picked_refs = [target_ref] - while True: - default_branch = default_pick_branch(branch_names, tuple(picked_refs)) - if default_branch is None: - print("Every known release branch already contains #%s; nothing to pick." % pr_num) - break - picked_refs = picked_refs + [cherry_pick(pr_num, merge_hash, default_branch)[0]] - prompt = "Would you like to pick %s into another branch?" % merge_hash - if input("\n%s (y/N): " % prompt).lower() != "y": - break + picked_commits = [] + try: + while True: + default_branch = default_pick_branch(branch_names, tuple(picked_refs)) + if default_branch is None: + print( + "Every known release branch already contains #%s; nothing to pick." + % pr_num + ) + break + picked = cherry_pick(pr_num, merge_hash, default_branch) + picked_refs = picked_refs + [picked[0]] + picked_commits = picked_commits + [picked] + prompt = "Would you like to pick %s into another branch?" % merge_hash + if input("\n%s (y/N): " % prompt).lower() != "y": + break + finally: + post_merge_comment(pr_num, picked_commits) sys.exit(0) if not bool(pr["mergeable"]): - msg = "Pull request %s is not mergeable in its current form.\n" % pr_num + \ - "Continue? (experts only!)" + msg = ( + "Pull request %s is not mergeable in its current form.\n" % pr_num + + "Continue? (experts only!)" + ) continue_maybe(msg) print("\n=== Pull Request #%s ===" % pr_num) - print("title:\t%s\nsource:\t%s\ntarget:\t%s\nurl:\t%s\nbody:\n\n%s" % - (title, pr_repo_desc, target_ref, url, body)) + print( + "title:\t%s\nsource:\t%s\ntarget:\t%s\nurl:\t%s\nbody:\n\n%s" + % (title, pr_repo_desc, target_ref, url, body) + ) - if assignees is None or len(assignees)==0: + if assignees is None or len(assignees) == 0: continue_maybe("Assignees have NOT been set. Continue?") else: print("assignees: %s" % [assignee["login"] for assignee in assignees]) @@ -312,17 +401,28 @@ def sort_by_version(branch_name): merged_refs = [target_ref] merge_hash = merge_pr(pr_num, target_ref, title, body, pr_repo_desc) + merged_commits = [(target_ref, merge_hash)] pick_prompt = "Would you like to pick %s into another branch?" % merge_hash - while input("\n%s (y/N): " % pick_prompt).lower() == "y": - default_branch = default_pick_branch(branch_names, tuple(merged_refs)) - if default_branch is None: - print("Every known release branch already contains #%s; nothing to pick." % pr_num) - break - merged_refs = merged_refs + [cherry_pick(pr_num, merge_hash, default_branch)[0]] + try: + while input("\n%s (y/N): " % pick_prompt).lower() == "y": + default_branch = default_pick_branch(branch_names, tuple(merged_refs)) + if default_branch is None: + print( + "Every known release branch already contains #%s; nothing to pick." + % pr_num + ) + break + picked = cherry_pick(pr_num, merge_hash, default_branch) + merged_refs = merged_refs + [picked[0]] + merged_commits = merged_commits + [picked] + finally: + post_merge_comment(pr_num, merged_commits) + if __name__ == "__main__": import doctest + (failure_count, test_count) = doctest.testmod() if failure_count: sys.exit(-1) From 76e7a6eb7505754bd6eaaf41b9e710d256a5cccf Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:04:44 +0800 Subject: [PATCH 4/8] [INFRA] Format merge script with Black --- dev/merge_kyuubi_pr.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 88f2468b0b6..8af75fe793a 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -47,13 +47,18 @@ def get_json(url): try: request = Request(url) if GITHUB_OAUTH_KEY: - request.add_header('Authorization', 'token %s' % GITHUB_OAUTH_KEY) + request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY) return json.load(urlopen(request)) except HTTPError as e: - if "X-RateLimit-Remaining" in e.headers and e.headers["X-RateLimit-Remaining"] == '0': - print("Exceeded the GitHub API rate limit; see the instructions in " + - "dev/merge_kyuubi_pr.py to configure an OAuth token for making authenticated " + - "GitHub requests.") + if ( + "X-RateLimit-Remaining" in e.headers + and e.headers["X-RateLimit-Remaining"] == "0" + ): + print( + "Exceeded the GitHub API rate limit; see the instructions in " + + "dev/merge_kyuubi_pr.py to configure an OAuth token for making authenticated " + + "GitHub requests." + ) else: print("Unable to fetch URL, exiting: %s" % url, e) sys.exit(-1) @@ -170,13 +175,16 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): continue_maybe(msg) had_conflicts = True - commit_authors = run_cmd(['git', 'log', 'HEAD..%s' % pr_branch_name, - '--pretty=format:%an <%ae>']).split("\n") - distinct_authors = sorted(set(commit_authors), - key=lambda x: commit_authors.count(x), reverse=True) + commit_authors = run_cmd( + ["git", "log", "HEAD..%s" % pr_branch_name, "--pretty=format:%an <%ae>"] + ).split("\n") + distinct_authors = sorted( + set(commit_authors), key=lambda x: commit_authors.count(x), reverse=True + ) primary_author = input( - "Enter primary author in the format of \"name \" [%s]: " % - distinct_authors[0]) + 'Enter primary author in the format of "name " [%s]: ' + % distinct_authors[0] + ) if primary_author == "": primary_author = distinct_authors[0] else: @@ -185,8 +193,9 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): distinct_authors = list(filter(lambda x: x != primary_author, distinct_authors)) distinct_authors.insert(0, primary_author) - commits = run_cmd(['git', 'log', 'HEAD..%s' % pr_branch_name, - '--pretty=format:%h [%an] %s']).split("\n\n") + commits = run_cmd( + ["git", "log", "HEAD..%s" % pr_branch_name, "--pretty=format:%h [%an] %s"] + ).split("\n\n") merge_message_flags = [] @@ -200,8 +209,10 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): committer_email = run_cmd("git config --get user.email").strip() if had_conflicts: - message = "This patch had conflicts when merged, resolved by\nCommitter: %s <%s>" % ( - committer_name, committer_email) + message = ( + "This patch had conflicts when merged, resolved by\nCommitter: %s <%s>" + % (committer_name, committer_email) + ) merge_message_flags += ["-m", message] # The string "Closes #%s" string is required for GitHub to correctly close the PR From 922beb31ef0a99adb8c469f7a99f7d0911364a78 Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:11:49 +0800 Subject: [PATCH 5/8] [INFRA] Detect merged pull requests robustly --- dev/merge_kyuubi_pr.py | 86 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 8af75fe793a..a9efd57b857 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -41,6 +41,10 @@ GITHUB_API_BASE = "https://api.github.com/repos/apache/kyuubi" GITHUB_COMMIT_BASE = "https://github.com/apache/kyuubi/commit" BRANCH_PREFIX = "PR_TOOL" +_MERGE_FOOTER_RE = re.compile( + r"^Closes #(\d+) from \S+\s*$\n\n(?:Lead-authored-by|Authored-by):", + re.MULTILINE, +) def get_json(url): @@ -146,6 +150,76 @@ def default_pick_branch(branch_names, already_picked): return remaining[0] if remaining else None +def merge_footer_pr(message): + """Return the PR number in the final generated merge footer. + + >>> footer = "Closes #1 from a/b.\\n\\nAuthored-by: A " + >>> merge_footer_pr("Title\\n\\n" + footer) + 1 + >>> merge_footer_pr("Title\\n\\nNo footer") is None + True + """ + matches = _MERGE_FOOTER_RE.findall(message) + return int(matches[-1]) if matches else None + + +def has_merge_footer(message, pr_num): + """Whether the final generated merge footer closes pr_num. + + >>> footer = "Closes #1 from a/b.\\n\\nAuthored-by: A " + >>> has_merge_footer("Title\\n\\n" + footer, 1) + True + >>> has_merge_footer("Title\\n\\n" + footer, 2) + False + """ + return merge_footer_pr(message) == int(pr_num) + + +def merge_commit_candidates(pr_events): + """Split merge events into closed and referenced commits, oldest first. + + >>> merge_commit_candidates([ + ... {"event": "closed", "commit_id": "a", "created_at": "2"}, + ... {"event": "referenced", "commit_id": "b", "created_at": "1"}, + ... ]) + (['a'], ['b']) + >>> merge_commit_candidates([{"event": "closed", "commit_id": None}]) + ([], []) + """ + + def commits_of(event_name): + matched = [ + event + for event in pr_events + if event["event"] == event_name and event["commit_id"] is not None + ] + return [ + event["commit_id"] + for event in sorted(matched, key=lambda event: event["created_at"]) + ] + + return commits_of("closed"), commits_of("referenced") + + +def find_merge_commit(pr_num, pr_events): + """Return the latest commit that merged pr_num, or None.""" + + def message_of(commit_hash): + return get_json("%s/commits/%s" % (GITHUB_API_BASE, commit_hash))["commit"][ + "message" + ] + + closed_commits, referenced_commits = merge_commit_candidates(pr_events) + if closed_commits: + return closed_commits[-1], message_of(closed_commits[-1]) + + for commit_hash in reversed(referenced_commits): + message = message_of(commit_hash) + if has_merge_footer(message, pr_num): + return commit_hash, message + return None, None + + def fix_title(text, num): if re.search(r"^\[KYUUBI\s#[0-9]{3,6}\].*", text): return text @@ -336,15 +410,11 @@ def sort_by_version(branch_name): assignees = pr["assignees"] milestone = pr["milestone"] - # Merged pull requests don't appear as merged in the GitHub API; - # Instead, they're closed by asfgit. - merge_commits = [e for e in pr_events if e["event"] == "closed" and e["commit_id"]] + merge_hash, message = (None, None) + if pr["state"] == "closed": + merge_hash, message = find_merge_commit(pr_num, pr_events) - if merge_commits: - merge_hash = merge_commits[0]["commit_id"] - message = get_json("%s/commits/%s" % (GITHUB_API_BASE, merge_hash))["commit"][ - "message" - ] + if merge_hash is not None: print( "Pull request %s has already been merged, assuming you want to backport" From 5fdb9ed68e10f0a1d8690b7eb385d9f6f2c93e2a Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:13:57 +0800 Subject: [PATCH 6/8] [INFRA] Validate merge script inputs and branches --- dev/merge_kyuubi_pr.py | 44 +++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index a9efd57b857..0e6de99fc6c 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -82,9 +82,22 @@ def run_cmd(cmd): return subprocess.check_output(cmd.split(" ")).decode("utf-8") +def get_input(prompt, options): + while True: + answer = input(prompt).strip() + if isinstance(options, str): + if re.fullmatch(options, answer): + return answer + else: + normalized_answer = answer.lower() + if normalized_answer in options: + return normalized_answer + print("Invalid input. Please try again.") + + def continue_maybe(prompt): - result = input("\n%s (y/N): " % prompt) - if result.lower() != "y": + result = get_input("\n%s (y/N): " % prompt, ["y", "n", ""]).lower() + if result != "y": fail("Okay, exiting") @@ -330,10 +343,17 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): return merge_hash -def cherry_pick(pr_num, merge_hash, default_branch): - pick_ref = input("Enter a branch name [%s]: " % default_branch) - if pick_ref == "": - pick_ref = default_branch +def cherry_pick(pr_num, merge_hash, default_branch, branch_names): + while True: + pick_ref = input("Enter a branch name [%s]: " % default_branch) + if pick_ref == "": + pick_ref = default_branch + if pick_ref in branch_names: + break + print( + "'%s' is not a known release branch. Valid branches: %s. Please try again." + % (pick_ref, ", ".join(branch_names)) + ) pick_branch_name = "%s_PICK_PR_%s_%s" % (BRANCH_PREFIX, pr_num, pick_ref.upper()) @@ -395,7 +415,9 @@ def sort_by_version(branch_name): branch_names = sorted(branch_names, key=sort_by_version, reverse=True) - pr_num = input("Which pull request would you like to merge? (e.g. 34): ") + pr_num = get_input( + "Which pull request would you like to merge? (e.g. 34): ", r"\d+" + ) pr = get_json("%s/pulls/%s" % (GITHUB_API_BASE, pr_num)) pr_events = get_json("%s/issues/%s/events" % (GITHUB_API_BASE, pr_num)) @@ -444,11 +466,11 @@ def sort_by_version(branch_name): % pr_num ) break - picked = cherry_pick(pr_num, merge_hash, default_branch) + picked = cherry_pick(pr_num, merge_hash, default_branch, branch_names) picked_refs = picked_refs + [picked[0]] picked_commits = picked_commits + [picked] prompt = "Would you like to pick %s into another branch?" % merge_hash - if input("\n%s (y/N): " % prompt).lower() != "y": + if get_input("\n%s (y/N): " % prompt, ["y", "n", ""]) != "y": break finally: post_merge_comment(pr_num, picked_commits) @@ -486,7 +508,7 @@ def sort_by_version(branch_name): pick_prompt = "Would you like to pick %s into another branch?" % merge_hash try: - while input("\n%s (y/N): " % pick_prompt).lower() == "y": + while get_input("\n%s (y/N): " % pick_prompt, ["y", "n", ""]) == "y": default_branch = default_pick_branch(branch_names, tuple(merged_refs)) if default_branch is None: print( @@ -494,7 +516,7 @@ def sort_by_version(branch_name): % pr_num ) break - picked = cherry_pick(pr_num, merge_hash, default_branch) + picked = cherry_pick(pr_num, merge_hash, default_branch, branch_names) merged_refs = merged_refs + [picked[0]] merged_commits = merged_commits + [picked] finally: From b85920c9cc25add117018ae01432a48d86f7c036 Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:14:55 +0800 Subject: [PATCH 7/8] [INFRA] Close branch-target pull requests explicitly --- dev/merge_kyuubi_pr.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 0e6de99fc6c..32c0b392021 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -149,6 +149,21 @@ def post_merge_comment(pr_num, merged_commits): comment_pr(pr_num, body) +def close_pr(pr_num): + url = "%s/pulls/%s" % (GITHUB_API_BASE, pr_num) + data = json.dumps({"state": "closed"}).encode("utf-8") + request = Request(url, data=data, method="PATCH") + request.add_header("Content-Type", "application/json") + request.add_header("Accept", "application/vnd.github+json") + if GITHUB_OAUTH_KEY: + request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY) + try: + return json.load(urlopen(request)) + except HTTPError as e: + print("Failed to close PR #%s: HTTP %s %s" % (pr_num, e.code, e.reason)) + return None + + def default_pick_branch(branch_names, already_picked): """Return the newest release branch that has not received the change. @@ -520,6 +535,10 @@ def sort_by_version(branch_name): merged_refs = merged_refs + [picked[0]] merged_commits = merged_commits + [picked] finally: + pr_state = get_json("%s/pulls/%s" % (GITHUB_API_BASE, pr_num)).get("state") + if pr_state != "closed": + print("PR #%s is still open after push; closing it explicitly." % pr_num) + close_pr(pr_num) post_merge_comment(pr_num, merged_commits) From ab89744f24df09fe14b3bfaca18117e39b80b580 Mon Sep 17 00:00:00 2001 From: Cheng Pan Date: Fri, 21 Aug 2026 16:15:10 +0800 Subject: [PATCH 8/8] [INFRA] Use GitHub data for merge authorship --- dev/merge_kyuubi_pr.py | 61 ++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 32c0b392021..031924c736c 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -256,7 +256,7 @@ def fix_title(text, num): # merge the requested PR and return the merge hash -def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): +def merge_pr(pr_num, target_ref, title, body, pr_repo_desc, pr_author, co_authors): pr_branch_name = "%s_MERGE_PR_%s" % (BRANCH_PREFIX, pr_num) target_branch_name = "%s_MERGE_PR_%s_%s" % ( BRANCH_PREFIX, @@ -277,23 +277,11 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): continue_maybe(msg) had_conflicts = True - commit_authors = run_cmd( - ["git", "log", "HEAD..%s" % pr_branch_name, "--pretty=format:%an <%ae>"] - ).split("\n") - distinct_authors = sorted( - set(commit_authors), key=lambda x: commit_authors.count(x), reverse=True - ) primary_author = input( - 'Enter primary author in the format of "name " [%s]: ' - % distinct_authors[0] + 'Enter primary author in the format of "name " [%s]: ' % pr_author ) if primary_author == "": - primary_author = distinct_authors[0] - else: - # When primary author is specified manually, de-dup it from author list and - # put it at the head of author list. - distinct_authors = list(filter(lambda x: x != primary_author, distinct_authors)) - distinct_authors.insert(0, primary_author) + primary_author = pr_author commits = run_cmd( ["git", "log", "HEAD..%s" % pr_branch_name, "--pretty=format:%h [%an] %s"] @@ -326,11 +314,11 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc): for c in commits: merge_message_flags += ["-m", c] - authors = "Authored-by:" if len(distinct_authors) == 1 else "Lead-authored-by:" - authors += " %s" % (distinct_authors.pop(0)) - if len(distinct_authors) > 0: + authors = "Authored-by:" if len(co_authors) == 0 else "Lead-authored-by:" + authors += " %s" % primary_author + if len(co_authors) > 0: authors += "\n" + "\n".join( - ["Co-authored-by: %s" % a for a in distinct_authors] + ["Co-authored-by: %s" % co_author for co_author in co_authors] ) authors += "\n" + "Signed-off-by: %s <%s>" % (committer_name, committer_email) @@ -447,6 +435,37 @@ def sort_by_version(branch_name): assignees = pr["assignees"] milestone = pr["milestone"] + pr_author_info = get_json("https://api.github.com/users/%s" % user_login) + pr_author_name = pr_author_info.get("name") or user_login + pr_author_email = pr_author_info.get("email") + pr_commits = get_json("%s/pulls/%s/commits" % (GITHUB_API_BASE, pr_num)) + if not pr_author_email: + for commit in pr_commits: + commit_author = commit.get("author") + if commit_author and commit_author.get("login") == user_login: + pr_author_email = commit["commit"]["author"]["email"] + break + if not pr_author_email: + pr_author_email = "%s+%s@users.noreply.github.com" % ( + pr_author_info["id"], + user_login, + ) + pr_author = "%s <%s>" % (pr_author_name, pr_author_email) + + co_authors = [] + seen_co_authors = set() + for commit in pr_commits: + commit_author = commit.get("author") + if commit_author and commit_author.get("login") == user_login: + continue + raw_author = "%s <%s>" % ( + commit["commit"]["author"]["name"], + commit["commit"]["author"]["email"], + ) + if raw_author not in seen_co_authors: + seen_co_authors.add(raw_author) + co_authors.append(raw_author) + merge_hash, message = (None, None) if pr["state"] == "closed": merge_hash, message = find_merge_commit(pr_num, pr_events) @@ -518,7 +537,9 @@ def sort_by_version(branch_name): merged_refs = [target_ref] - merge_hash = merge_pr(pr_num, target_ref, title, body, pr_repo_desc) + merge_hash = merge_pr( + pr_num, target_ref, title, body, pr_repo_desc, pr_author, co_authors + ) merged_commits = [(target_ref, merge_hash)] pick_prompt = "Would you like to pick %s into another branch?" % merge_hash