diff --git a/dev/merge_kyuubi_pr.py b/dev/merge_kyuubi_pr.py index 5894ebdf212..031924c736c 100755 --- a/dev/merge_kyuubi_pr.py +++ b/dev/merge_kyuubi_pr.py @@ -39,20 +39,30 @@ 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" +_MERGE_FOOTER_RE = re.compile( + r"^Closes #(\d+) from \S+\s*$\n\n(?:Lead-authored-by|Authored-by):", + re.MULTILINE, +) 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) @@ -67,19 +77,32 @@ 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 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") 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) @@ -89,23 +112,164 @@ def clean_up(): print("Deleting local branch %s" % branch) 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 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. + + >>> 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 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)): + 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): +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, 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) @@ -113,23 +277,15 @@ 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']).split("\n\n") + commits = run_cmd( + ["git", "log", "HEAD..%s" % pr_branch_name, "--pretty=format:%h [%an] %s"] + ).split("\n\n") merge_message_flags = [] @@ -143,8 +299,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 @@ -156,36 +314,49 @@ 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 += "\n" + "\n".join(["Co-authored-by: %s" % a for a in distinct_authors]) + 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" % co_author for co_author in co_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) 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()) @@ -195,30 +366,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 + 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: @@ -232,13 +409,18 @@ 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('.'))) - latest_branch = sorted(branch_names, key=sort_by_version, reverse=True)[0] + 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): ") + 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)) @@ -253,35 +435,95 @@ 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"]] - - 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() != "" + 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) + + if merge_hash is not None: + + 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)) - cherry_pick(pr_num, merge_hash, latest_branch) + picked_refs = [target_ref] + 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, 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 get_input("\n%s (y/N): " % prompt, ["y", "n", ""]) != "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]) @@ -295,14 +537,35 @@ 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 - while input("\n%s (y/n): " % pick_prompt).lower() == "y": - merged_refs = merged_refs + [cherry_pick(pr_num, merge_hash, latest_branch)] + try: + 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( + "Every known release branch already contains #%s; nothing to pick." + % pr_num + ) + break + picked = cherry_pick(pr_num, merge_hash, default_branch, branch_names) + 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) + if __name__ == "__main__": import doctest + (failure_count, test_count) = doctest.testmod() if failure_count: sys.exit(-1)