Parcourir la source

ZOOKEEPER-4756: Merge script should use GitHub api to merge pull requ…

ZOOKEEPER-4756: Merge script should use GitHub api to merge pull requests

Change-Id: I22ee835617fd96b540edd65191f6c83aae5365a9
Fix check status handling in merge_pr function

Change-Id: I99844bfac98c90e9bb525cb8d3eae5a465a56629
Refactor JIRA ID extraction pattern

Change-Id: I85a458eaac03b2a76edbc2ec923d56467503f900
Reviewers: tisonkun
Author: szucsvillo
Closes #2092 from szucsvillo/ZOOKEEPER-4756
szucsvillo il y a 1 an
Parent
commit
32fb89c9f7
1 fichiers modifiés avec 98 ajouts et 118 suppressions
  1. 98 118
      zk-merge-pr.py

+ 98 - 118
zk-merge-pr.py

@@ -34,6 +34,7 @@ import subprocess
 import sys
 import urllib.request, urllib.error, urllib.parse
 import getpass
+import requests
 
 try:
     import jira.client
@@ -123,96 +124,72 @@ def get_current_branch():
     return run_cmd("git rev-parse --abbrev-ref HEAD").replace("\n", "")
 
 # 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" % (TEMP_BRANCH_PREFIX, pr_num)
-    target_branch_name = "%s_MERGE_PR_%s_%s" % (TEMP_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'])
-    except Exception as e:
-        msg = "Error merging: %s\nWould you like to manually fix-up this merge?" % e
-        continue_maybe(msg)
-        msg = "Okay, please fix any conflicts and 'git add' conflicting files... Finished?"
-        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 <email>\" [%s]: " %
-        distinct_authors[0])
-    if primary_author == "":
-        primary_author = distinct_authors[0]
-
-    reviewers = input(
-        "Enter reviewers in the format of \"name1 <email1>, name2 <email2>\": ").strip()
-
-    commits = run_cmd(['git', 'log', 'HEAD..%s' % pr_branch_name,
-                      '--pretty=format:%h [%an] %s']).split("\n")
-
-    if len(commits) > 1:
-        result = input("List pull request commits in squashed commit message? (y/n): ")
-        if result.lower().strip() == "y":
-          should_list_commits = True
-        else:
-          should_list_commits = False
+def merge_pr(pr_num, title, pr_repo_desc):
+
+    # Retrieve the commits separately.
+    json_commits = get_json(f"https://api.github.com/repos/{PUSH_REMOTE_NAME}/{PROJECT_NAME}/pulls/{pr_num}/commits")
+    merge_message = []
+    if json_commits and isinstance(json_commits, list):
+        for commit in json_commits:
+            commit_message = commit['commit']['message']
+            merge_message += [commit_message]
+
+    # Check for disapproval reviews.
+    json_reviewers = get_json(f"https://api.github.com/repos/{PUSH_REMOTE_NAME}/{PROJECT_NAME}/pulls/{pr_num}/reviews")
+    disapproval_reviews = [review['user']['login'] for review in json_reviewers if review['state'] == 'CHANGES_REQUESTED']
+    if disapproval_reviews:
+        continue_maybe("Warning: There are requested changes. Proceed with merging pull request #%s?" % pr_num)
+    # Verify if there are no approved reviews.
+    approved_reviewers = [review['user']['login'] for review in json_reviewers if review['state'] == 'APPROVED']
+    if not approved_reviewers:
+        continue_maybe("Warning: Pull Request does not have an approved review. Proceed with merging pull request #%s?" % pr_num)
     else:
-        should_list_commits = False
-
-    merge_message_flags = []
-
-    merge_message_flags += ["-m", title]
-    if body is not None:
-        # We remove @ symbols from the body to avoid triggering e-mails
-        # to people every time someone creates a public fork of the project.
-        merge_message_flags += ["-m", body.replace("@", "")]
-
-    authors = "\n".join(["Author: %s" % a for a in distinct_authors])
-
-    merge_message_flags += ["-m", authors]
-
-    if (reviewers != ""):
-        merge_message_flags += ["-m", "Reviewers: %s" % reviewers]
-
-    if had_conflicts:
-        committer_name = run_cmd("git config --get user.name").strip()
-        committer_email = run_cmd("git config --get user.email").strip()
-        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
+        reviewers_string = ', '.join(approved_reviewers)
+        merge_message += [f"Reviewers: {reviewers_string}"]
+    # Check the author and the closing line.
+    json_pr = get_json(f"https://api.github.com/repos/{PUSH_REMOTE_NAME}/{PROJECT_NAME}/pulls/{pr_num}")
+    primary_author = json_pr["user"]["login"]
+    if primary_author != "":
+        merge_message += [f"Author: {primary_author}"]
     close_line = "Closes #%s from %s" % (pr_num, pr_repo_desc)
-    if should_list_commits:
-        close_line += " and squashes the following commits:"
-    merge_message_flags += ["-m", close_line]
-
-    if should_list_commits:
-        merge_message_flags += ["-m", "\n".join(commits)]
-
-    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))
-
-    try:
-        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]
-    clean_up()
-    print(("Pull request #%s merged!" % pr_num))
-    print(("Merge hash: %s" % merge_hash))
-    return merge_hash
-
+    merge_message += [close_line]
+    merged_string = '\n'.join(merge_message)
+
+    # Get the latest commit SHA.
+    latest_commit_sha = json_pr["head"]["sha"]
+    json_status = get_json(f"https://api.github.com/repos/{PUSH_REMOTE_NAME}/{PROJECT_NAME}/commits/{latest_commit_sha}/check-runs")
+    # Check if all checks have passed on GitHub.
+    all_checks_passed = all(status["conclusion"] == "success" for status in json_status["check_runs"])
+    if all_checks_passed:
+        print("All checks have passed on the github.")
+    else:
+        any_in_progress = any(run["status"] == "in_progress" for run in json_status["check_runs"])
+        if any_in_progress:
+            continue_maybe("Warning: There are pending checks. Would you like to continue the merge?")
+        else:
+            continue_maybe("Warning: Not all checks have passed on GitHub. Would you like to continue the merge?")
+
+    headers = {
+        "Authorization": f"token {GITHUB_OAUTH_KEY}",
+        "Accept": "application/vnd.github.v3+json"
+    }
+    data = {
+        "commit_title": title,
+        "commit_message": merged_string,
+        "merge_method": "squash"
+    }
+
+    response = requests.put(f"https://api.github.com/repos/{PUSH_REMOTE_NAME}/{PROJECT_NAME}/pulls/{pr_num}/merge", headers=headers, json=data)
+
+    if response.status_code == 200:
+        merge_response_json = response.json()
+        merge_commit_sha = merge_response_json.get("sha")
+        print(f"Pull request #{pr_num} merged. Sha: #{merge_commit_sha}")
+        return merge_commit_sha
+    else:
+        print(f"Failed to merge pull request #{pr_num}. Status code: {response.status_code}")
+        print(response.text)
+        exit()
 
 def cherry_pick(pr_num, merge_hash, default_branch):
     pick_ref = input("Enter a branch name [%s]: " % default_branch)
@@ -221,8 +198,8 @@ def cherry_pick(pr_num, merge_hash, default_branch):
 
     pick_branch_name = "%s_PICK_PR_%s_%s" % (TEMP_BRANCH_PREFIX, pr_num, pick_ref.upper())
 
-    run_cmd("git fetch %s %s:%s" % (PUSH_REMOTE_NAME, pick_ref, pick_branch_name))
-    run_cmd("git checkout %s" % pick_branch_name)
+    run_cmd("git fetch %s" % PUSH_REMOTE_NAME)
+    run_cmd("git checkout -b %s %s/%s" % (pick_branch_name, PUSH_REMOTE_NAME, pick_ref))
 
     try:
         run_cmd("git cherry-pick -sx %s" % merge_hash)
@@ -321,7 +298,7 @@ def resolve_jira_issue(merge_branches, comment, default_jira_id=""):
 
 
 def resolve_jira_issues(title, merge_branches, comment):
-    jira_ids = re.findall("%s-[0-9]{4,5}" % CAPITALIZED_PROJECT_NAME, title)
+    jira_ids = re.findall("%s-[0-9]+" % CAPITALIZED_PROJECT_NAME, title)
 
     if len(jira_ids) == 0:
         resolve_jira_issue(merge_branches, comment)
@@ -446,7 +423,35 @@ def main():
 
     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))
-    pr_events = get_json("%s/issues/%s/events" % (GITHUB_API_BASE, pr_num))
+
+    # Check if the pull request has already been closed or merged.
+    pull_request_state = pr.get("state", "")
+    if pull_request_state == "closed":
+        merge_hash = pr.get("merge_commit_sha", "")
+        merged = pr.get("merged")
+        # Verify if the pull request has been merged by the GitHub API.
+        if merged is True:
+            print(f"Pull request #{pr['number']} has already been merged, assuming you want to backport")
+            cherry_pick(pr_num, merge_hash, latest_branch)
+            sys.exit(0)
+        # Some merged pull requests may not appear as merged in the GitHub API,
+        # for example, those closed by an older version of this script.
+        else:
+            pr_events = get_json("%s/issues/%s/events" % (GITHUB_API_BASE, pr_num))
+            for event in pr_events:
+                if event.get("event") == "closed":
+                    commit_id = event.get("commit_id")
+                    if commit_id is not None:
+                        print(f"Pull request #{pr['number']} has already been merged, assuming you want to backport")
+                        cherry_pick(pr_num, merge_hash, latest_branch)
+                        sys.exit(0)
+                    else:
+                        print(f"Pull request #{pr['number']} has already been closed, but not merged, exiting.")
+                        exit()
+
+    if not bool(pr["mergeable"]):
+        print(f"Pull request %s is not mergeable in its current form.\n" % pr_num)
+        exit()
 
     url = pr["url"]
 
@@ -469,36 +474,11 @@ def main():
             print("Using original title:")
         print(commit_title)
 
-    body = pr["body"]
     target_ref = pr["base"]["ref"]
     user_login = pr["user"]["login"]
     base_ref = pr["head"]["ref"]
     pr_repo_desc = "%s/%s" % (user_login, base_ref)
 
-    # 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["actor"]["login"] == "asfgit" and e["event"] == "closed"]
-
-    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() != ""
-        if not commit_is_downloaded:
-            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)
-        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!)"
-        continue_maybe(msg)
-
     print(("\n=== Pull Request #%s ===" % pr_num))
     print(("PR title\t%s\nCommit title\t%s\nSource\t\t%s\nTarget\t\t%s\nURL\t\t%s" % (
         pr_title, commit_title, pr_repo_desc, target_ref, url)))
@@ -506,7 +486,7 @@ def main():
 
     merged_refs = [target_ref]
 
-    merge_hash = merge_pr(pr_num, target_ref, commit_title, body, pr_repo_desc)
+    merge_hash = merge_pr(pr_num, commit_title, 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().strip() == "y":