| #!/usr/bin/env python3 |
| |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| # Utility for creating well-formed pull request merges and pushing them to |
| # Apache. |
| # |
| # usage: ./gobblin-pr (see config env vars below) |
| # |
| # This utility assumes you already have a local Gobblin git folder and that you |
| # have added remotes corresponding to both (i) the github apache Gobblin |
| # mirror and (ii) the apache git repo. |
| |
| # This tool is based on the Spark merge_spark_pr script: |
| # https://github.com/apache/spark/blob/master/dev/merge_spark_pr.py |
| |
| from __future__ import print_function |
| import json |
| import os |
| import re |
| import subprocess |
| import sys |
| import textwrap |
| |
| # Print current SSL version, commented and left around for debugging |
| # import ssl |
| # print(ssl.OPENSSL_VERSION) |
| |
| # Enabling following lines if you found SSL: CERTIFICATE_VERIFY_FAILED with Python3 |
| #import ssl |
| #ssl._create_default_https_context = ssl._create_unverified_context |
| |
| # Python 3 compatibility |
| try: |
| import urllib2 as urllib |
| except ImportError: |
| import urllib.request as urllib |
| if sys.version_info[0] == 3: |
| raw_input = input |
| |
| try: |
| import click |
| except ImportError: |
| print("Could not find the click library. Run 'sudo pip3 install click' to install.") |
| sys.exit(-1) |
| |
| try: |
| import keyring |
| except ImportError: |
| print("Could not find the keyring library. Run 'sudo pip3 install keyring' to install.") |
| sys.exit(-1) |
| |
| try: |
| import jira.client |
| except ImportError: |
| print("Could not find jira-python library; exiting. Run 'sudo pip3 install jira' to install.") |
| sys.exit(-1) |
| |
| # Location of your Gobblin git development area |
| GOBBLIN_GIT_LOCATION = os.environ.get( |
| "GOBBLIN_GIT", |
| os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) |
| |
| # Remote name which points to the Gihub site |
| GITHUB_REMOTE_NAME = os.environ.get("GITHUB_REMOTE_NAME", "github") |
| # Remote name which points to Apache git |
| APACHE_REMOTE_NAME = os.environ.get("APACHE_REMOTE_NAME", "apache") |
| # OAuth key used for issuing requests against the GitHub API. If this is not defined, then requests |
| # will be unauthenticated. You should only need to configure this if you find yourself regularly |
| # exceeding your IP's unauthenticated request rate limit. You can create an OAuth key at |
| # https://github.com/settings/tokens. This tool only requires the "public_repo" scope. |
| GITHUB_OAUTH_KEY = os.environ.get("GITHUB_OAUTH_KEY") |
| |
| GITHUB_BASE = "https://github.com/apache/gobblin/pull" |
| GITHUB_API_BASE = "https://api.github.com/repos/apache/gobblin" |
| GITHUB_USER = 'asfgit' |
| |
| JIRA_BASE = "https://issues.apache.org/jira/browse" |
| JIRA_API_BASE = "https://issues.apache.org/jira" |
| # Prefix added to temporary branches |
| BRANCH_PREFIX = "PR_TOOL" |
| |
| TMP_CREDENTIALS = {} |
| |
| |
| class PRToolError(Exception): |
| pass |
| |
| |
| def reflow(text, width=80): |
| """ |
| Reformats text so that each line does not exceed `width` characters. |
| |
| Preserves any whitespace that follows a newline, such as paragraph breaks |
| and indentation. |
| """ |
| new_text = textwrap.dedent(text).strip() |
| split = re.split('(\n+\s*)', new_text) |
| paragraphs, whitespace = split[::2], split[1::2] |
| reformatted = [textwrap.fill(p, width=width) for p in paragraphs] |
| result = reformatted + whitespace |
| result[::2] = reformatted |
| result[1::2] = whitespace |
| return ''.join(result) |
| |
| |
| def get_json(url): |
| try: |
| request = urllib.Request(url) |
| if GITHUB_OAUTH_KEY: |
| request.add_header('Authorization', 'token %s' % GITHUB_OAUTH_KEY) |
| |
| # decode response for Py3 compatibility |
| response = urllib.urlopen(request).read().decode('utf-8') |
| return json.loads(response) |
| except urllib.HTTPError as e: |
| if ( |
| "X-RateLimit-Remaining" in e.headers and |
| e.headers["X-RateLimit-Remaining"] == '0'): |
| click.echo(reflow( |
| "Exceeded the GitHub API rate limit; set the environment " |
| "variable GITHUB_OAUTH_KEY in order to make authenticated " |
| "GitHub requests.")) |
| else: |
| click.echo("Unable to fetch URL, exiting: %s" % url) |
| sys.exit(-1) |
| |
| |
| def fail(msg): |
| click.echo(msg) |
| clean_up() |
| sys.exit(-1) |
| |
| |
| def run_cmd(cmd, echo_cmd=True): |
| if isinstance(cmd, list): |
| if echo_cmd: |
| click.echo('>> Running command: {}'.format(' '.join(cmd))) |
| return subprocess.check_output(cmd).decode('utf-8').strip() |
| else: |
| if echo_cmd: |
| click.echo('>> Running command: {}'.format(cmd)) |
| return subprocess.check_output(cmd.split(" ")).decode('utf-8').strip() |
| |
| |
| def continue_maybe(prompt): |
| if not click.confirm(click.style(prompt, fg='blue', bold=True)): |
| fail("Okay, exiting.") |
| |
| |
| def clean_up(): |
| if 'original_head' not in globals(): |
| return |
| |
| click.echo('Resetting git to remove any changes') |
| run_cmd('git reset --hard') |
| |
| click.echo("Restoring head pointer to %s" % original_head) |
| run_cmd("git checkout %s" % original_head) |
| |
| branches = run_cmd("git branch").replace(" ", "").split("\n") |
| |
| for branch in filter(lambda x: x.startswith(BRANCH_PREFIX), branches): |
| click.echo("Deleting local branch %s" % branch) |
| run_cmd("git branch -D %s" % branch) |
| |
| |
| # merge the requested PR and return the merge hash |
| def merge_pr(pr_num, target_ref, title, body, pr_repo_desc, local): |
| |
| 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()) |
| run_cmd("git fetch %s pull/%s/head:%s" % (GITHUB_REMOTE_NAME, pr_num, pr_branch_name)) |
| run_cmd("git fetch %s %s:%s" % (APACHE_REMOTE_NAME, target_ref, target_branch_name)) |
| run_cmd("git checkout %s" % target_branch_name) |
| |
| had_conflicts = False |
| squash = click.confirm('\n'.join([ |
| click.style('\nDo you want to squash the PR?\n', bold=True), |
| reflow( |
| """ |
| We recommend that you do! |
| |
| Squashing will give you an opportunity to edit the new commit |
| message, but the squashed commit will still be attributed to the PR |
| author. GitHub will show that you merged the squash commit but will |
| mark the PR as closed rather than merged (the distinction is purely |
| cosmetic). |
| |
| If you don't squash, a merge commit will be created in addition to |
| the PR commits, but GitHub will properly show the PR as "merged". |
| We suggest you do this only if the PR commits are logically |
| distinct and should remain separate. |
| """), |
| click.style('Squash?', fg='blue', bold=True)]), |
| default=True) |
| |
| if squash: |
| merge_cmd = ['git', 'merge', pr_branch_name, '--squash'] |
| else: |
| merge_cmd = ['git', 'merge', pr_branch_name, '--no-ff', '--no-commit'] |
| try: |
| run_cmd(merge_cmd) |
| 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 |
| |
| pr_commits = get_json("{}/pulls/{}/commits".format(GITHUB_API_BASE, pr_num)) |
| |
| # find all JIRA issues mentioned in the text |
| all_text = title + body |
| if pr_commits: |
| all_text += ' '.join(c['commit']['message'] for c in pr_commits) |
| all_jira_refs = standardize_jira_ref(all_text, only_jira=True) |
| all_jira_issues = re.findall("GOBBLIN-[0-9]{1,6}", all_jira_refs) |
| |
| merge_message_flags = [] |
| |
| if squash: |
| |
| # -- create commit message subject |
| # if there is only one commit, take the squash commit message from it |
| if len(pr_commits) == 1: |
| click.echo(click.style('\n' + reflow( |
| """ |
| This squash contains only one commit, so we will use its |
| commit message for the squash commit message. We will |
| automatically add references to every JIRA issue |
| mentioned in the PR. You will have an opportunity to edit |
| it later. |
| """), bold=True)) |
| commit_message = pr_commits[0]['commit']['message'] |
| merge_message_flags.extend(["-m", commit_message]) |
| # if there is are multiple commits, take the squash commit message from |
| # the PR title |
| else: |
| click.echo(click.style('\n' + reflow( |
| """ |
| This squash contains more than one commit, so we will use |
| the PR title as the squash commit subject. We will |
| automatically add references to every JIRA issue mentioned in |
| the PR. You will have an opportunity to edit it later. |
| """), bold=True)) |
| merge_message_flags.extend(["-m", title]) |
| |
| # add all JIRA refs to the commit subject and then standardize it |
| # to get a clean reference to every JIRA issue mentioned in the PR |
| first_commit_msg = merge_message_flags[-1].split('\n') |
| first_commit_msg[0] = standardize_jira_ref( |
| first_commit_msg[0] + all_jira_refs) |
| if not re.findall("GOBBLIN-[0-9]{1,6}", first_commit_msg[0]): |
| continue_maybe(click.style('\n' + reflow( |
| """ |
| This PR doesn't reference any JIRA issues!! |
| |
| Are you sure you want to continue? |
| """), fg='red', bold=True)) |
| merge_message_flags[-1] = '\n'.join(first_commit_msg) |
| |
| # -- Note conflicts |
| if had_conflicts: |
| committer_name = run_cmd( |
| "git config --get user.name", echo_cmd=False) |
| committer_email = run_cmd( |
| "git config --get user.email", echo_cmd=False) |
| message = ( |
| 'This patch had conflicts when merged, resolved by ' |
| 'Committer: %s <%s>' % (committer_name, committer_email)) |
| merge_message_flags.extend(["-m", message]) |
| |
| # -- Add PR body to commit message |
| msg = click.style( |
| '\nWould you like to include the PR body in the squash ' |
| 'commit message?', |
| fg='blue', bold=True) |
| if body and click.confirm(msg, default=False, prompt_suffix=''): |
| # We remove @ symbols from the body to avoid triggering e-mails |
| # to people every time someone creates a public fork of Gobblin. |
| merge_message_flags += ["-m", body.replace("@", "")] |
| |
| # -- add individual commit messages to squash commit |
| if len(pr_commits) > 1: |
| m = click.style( |
| 'Would you like to include the individual commit messages ' |
| 'in the squash commit message?', |
| fg='blue', bold=True) |
| if pr_commits and click.confirm(m, default=True, prompt_suffix=''): |
| for commit in pr_commits: |
| merge_message_flags.extend( |
| ['-m', commit['commit']['message']]) |
| |
| # The string "Closes #%s" string is required for GitHub to correctly |
| # close the PR. GitHub will mark the PR as closed, not merged |
| close_msg = "closes #{}".format(pr_num) |
| merge_message_flags.extend(["-m", "{} from {}".format( |
| close_msg.capitalize(), pr_repo_desc)]) |
| |
| # -- set authors and add authors to commit message |
| commit_authors = run_cmd( |
| ['git', 'log', 'HEAD..{}'.format(pr_branch_name), |
| '--pretty=format:%an <%ae>'], echo_cmd=False).split("\n") |
| distinct_authors = sorted( |
| set(commit_authors), |
| key=lambda x: commit_authors.count(x), |
| reverse=True) |
| primary_author = click.prompt( |
| click.style( |
| 'Enter the primary author in the format of \"name <email>\"', |
| fg='blue', bold=True), |
| default=distinct_authors[0]) |
| if primary_author == "": |
| primary_author = distinct_authors[0] |
| |
| authors = "\n".join(["Author: %s" % a for a in distinct_authors]) |
| merge_message_flags.append(u'--author="{}"'.format(primary_author)) |
| |
| else: |
| # This will mark the PR as merged |
| merge_message_flags.extend([ |
| '-m', |
| 'Merge pull request #{} from {}'.format(pr_num, pr_repo_desc)]) |
| |
| # reflow commit message |
| seen_first_line = False |
| for i in range(1, len(merge_message_flags)): |
| if merge_message_flags[i-1] == '-m': |
| # let the first line be as long as the user wants |
| if not seen_first_line: |
| if '\n\n' in merge_message_flags[i]: |
| title, body = merge_message_flags[i].split('\n\n', 1) |
| body = reflow(body, 50) |
| merge_message_flags[i] = title + '\n\n' + body |
| seen_first_line = True |
| else: |
| merge_message_flags[i] = reflow(merge_message_flags[i], 50) |
| |
| run_cmd(['git', 'commit'] + merge_message_flags, echo_cmd=False) |
| |
| if squash: |
| # -- ask user to edit commit message |
| click.echo(click.style('\n=== Current Squash Commit ===', bold=True)) |
| click.echo(run_cmd('git log -1 --pretty=%B', echo_cmd=False)) |
| click.echo(click.style('=== End of Squash Commit ===\n', bold=True)) |
| msg = reflow( |
| """ |
| If you would like to edit the commit message, open a new |
| terminal and run: |
| |
| git commit --amend |
| |
| When you have finished, return here and press any key to |
| continue. |
| """) |
| click.pause(click.style(msg, fg='blue', bold=True)) |
| |
| # The user might have removed "Closes #XXXX" from the commit message |
| # so we add it back to make sure GitHub closes the PR. |
| commit_msg = run_cmd('git log -1 --pretty=%B', echo_cmd=False) |
| if close_msg not in commit_msg.lower(): |
| click.echo(reflow(""" |
| Your commit message does not contain the phrase "{}". |
| Without it, GitHub can\'t link this commit to the PR. We |
| will automatically add it to the end of your commit |
| message.""".format(close_msg))) |
| commit_flags = [] |
| commit_flags.append('--author="{}"'.format(primary_author)) |
| commit_flags.extend(['-m', commit_msg]) |
| commit_flags.extend( |
| ["-m", "{} from {}".format( |
| close_msg.capitalize(), pr_repo_desc)]) |
| run_cmd('git reset --soft HEAD~1', echo_cmd=False) |
| run_cmd(['git', 'commit'] + commit_flags, echo_cmd=False) |
| |
| if local: |
| msg ='\n' + reflow(""" |
| The PR has been merged locally in branch {}. |
| You may leave this program running while you work on it. When |
| you are finished, press any key to delete the PR branch and |
| restore your original environment. |
| """.format(target_branch_name)) |
| |
| click.pause(click.style(msg, fg='blue', bold=True)) |
| |
| clean_up() |
| return |
| else: |
| continue_maybe( |
| '\n\nThe local merge is complete ({}).\n'.format( |
| target_branch_name) |
| + click.style( |
| 'Push to Apache ({})?'.format(APACHE_REMOTE_NAME), 'red')) |
| |
| try: |
| run_cmd('git push %s %s:%s' % ( |
| APACHE_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() |
| click.echo("Pull request #%s merged!" % pr_num) |
| click.echo("Merge hash: %s" % merge_hash) |
| return merge_hash |
| |
| |
| def cherry_pick(pr_num, merge_hash, default_branch): |
| pick_ref = click.prompt(click.style( |
| "Enter a branch name (or press enter to use %s): " % default_branch, |
| fg='blue', bold=True)) |
| if pick_ref == "": |
| pick_ref = default_branch |
| |
| pick_branch_name = "%s_PICK_PR_%s_%s" % ( |
| BRANCH_PREFIX, pr_num, pick_ref.upper()) |
| |
| run_cmd("git fetch %s %s:%s" % ( |
| APACHE_REMOTE_NAME, pick_ref, pick_branch_name)) |
| run_cmd("git checkout %s" % pick_branch_name) |
| |
| try: |
| run_cmd("git cherry-pick -sx %s" % merge_hash) |
| except Exception as e: |
| msg = ( |
| "Error cherry-picking: {}\n" |
| "Would you like to manually fix-up this merge?".format(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, APACHE_REMOTE_NAME)) |
| |
| try: |
| run_cmd( |
| 'git push %s %s:%s' % ( |
| APACHE_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] |
| clean_up() |
| |
| click.echo("Pull request #%s picked into %s!" % (pr_num, pick_ref)) |
| click.echo("Pick hash: %s" % pick_hash) |
| return pick_ref |
| |
| |
| def fix_version_from_branch(branch, versions): |
| # Note: Assumes this is a sorted (newest->oldest) list of un-released |
| # versions |
| if branch == "master": |
| return versions[0] |
| else: |
| #TODO adopt a release scheme with branches. Spark uses branch-XX. |
| branch_ver = branch.replace("branch-", "") |
| versions = list(filter( |
| lambda x: x.name.startswith(branch_ver), versions)) |
| if versions: |
| return versions[-1] |
| |
| |
| def register(username, password): |
| """ Use this function to register a JIRA account in your OS' keyring """ |
| keyring.set_password('gobblin-pr', username, password) |
| |
| def validate_jira_id(jira_id): |
| if not jira_id: |
| return |
| elif isinstance(jira_id, int): |
| return 'GOBBLIN-{}'.format(abs(jira_id)) |
| |
| # first look for GOBBLIN-X |
| ids = re.findall("GOBBLIN-[0-9]{1,6}", jira_id) |
| if len(ids) > 1: |
| raise click.UsageError('Found multiple issue ids: {}'.format(ids)) |
| elif len(ids) == 1: |
| jira_id = ids[0] |
| elif not ids: |
| # if we don't find GOBBLIN-X, see if jira_id is an int |
| try: |
| jira_id = 'GOBBLIN-{}'.format(abs(int(jira_id))) |
| except ValueError: |
| raise click.UsageError( |
| 'JIRA id must be an integer or have the form GOBBLIN-X') |
| |
| return jira_id |
| |
| |
| def resolve_jira_issues_loop(comment=None, merge_branches=None): |
| """ |
| Resolves a JIRA issue, then asks the user if he/she would like to close |
| another one. Repeats until the user indicates they are finished. |
| """ |
| while True: |
| try: |
| resolve_jira_issue( |
| comment=comment, |
| jira_id=None, |
| merge_branches=merge_branches) |
| except PRToolError as e: |
| click.echo("PR Tool Error: {}".format(e)) |
| sys.exit(-1) |
| except Exception as e: |
| click.echo("ERROR: {}".format(e)) |
| |
| if not click.confirm(click.style( |
| 'Would you like to resolve another JIRA issue?', |
| fg='blue', bold=True)): |
| return |
| |
| |
| def resolve_jira_issue(comment=None, jira_id=None, merge_branches=None): |
| """ |
| Resolves a JIRA issue |
| |
| comment: a comment for the issue. The user will always be prompted for one; |
| if provided, this will be the default. |
| |
| jira_id: an Gobblin JIRA id, either an integer or a string with the form |
| GOBBLIN-X. If not provided, the user will be prompted to provide one. |
| """ |
| |
| if merge_branches is None: |
| merge_branches = [] |
| |
| # ASF JIRA username |
| JIRA_USERNAME = os.environ.get("JIRA_USERNAME", '') |
| if not JIRA_USERNAME: |
| JIRA_USERNAME = TMP_CREDENTIALS.get('JIRA_USERNAME', '') |
| # ASF JIRA password |
| JIRA_PASSWORD = os.environ.get("JIRA_PASSWORD", '') |
| if not JIRA_PASSWORD: |
| JIRA_PASSWORD = TMP_CREDENTIALS.get('JIRA_PASSWORD', '') |
| |
| if not JIRA_USERNAME: |
| JIRA_USERNAME = click.prompt( |
| click.style('Username for Gobblin JIRA', fg='blue', bold=True), |
| type=str) |
| click.echo( |
| 'Set a JIRA_USERNAME env var to avoid this prompt in the future.') |
| TMP_CREDENTIALS['JIRA_USERNAME'] = JIRA_USERNAME |
| if JIRA_USERNAME and not JIRA_PASSWORD: |
| JIRA_PASSWORD = keyring.get_password("gobblin-pr", JIRA_USERNAME) |
| if JIRA_PASSWORD: |
| click.echo("Obtained password from keyring. To reset remove it there.") |
| if not JIRA_PASSWORD: |
| JIRA_PASSWORD = click.prompt( |
| click.style('Password for Gobblin JIRA', fg='blue', bold=True), |
| type=str, |
| hide_input=True) |
| if JIRA_USERNAME and JIRA_PASSWORD: |
| if click.confirm(click.style("Would you like to store your password " |
| "in your keyring?", fg='blue', bold=True)): |
| register(JIRA_USERNAME, JIRA_PASSWORD) |
| TMP_CREDENTIALS['JIRA_PASSWORD'] = JIRA_PASSWORD |
| |
| try: |
| asf_jira = jira.client.JIRA( |
| {'server': JIRA_API_BASE}, |
| basic_auth=(JIRA_USERNAME, JIRA_PASSWORD)) |
| except: |
| raise ValueError('Could not log in to JIRA!') |
| |
| if jira_id is None: |
| jira_id = click.prompt(click.style( |
| 'Enter an Gobblin JIRA id', fg='blue', bold=True), |
| value_proc=validate_jira_id) |
| else: |
| jira_id = validate_jira_id(jira_id) |
| |
| try: |
| issue = asf_jira.issue(jira_id) |
| except Exception as e: |
| raise ValueError( |
| "ASF JIRA could not find issue {}\n{}".format(jira_id, e)) |
| |
| cur_status = issue.fields.status.name |
| cur_summary = issue.fields.summary |
| cur_assignee = issue.fields.assignee |
| if cur_assignee is None: |
| cur_assignee = "NOT ASSIGNED!!!" |
| else: |
| cur_assignee = cur_assignee.displayName |
| |
| # check if issue was already closed |
| if cur_status == "Resolved" or cur_status == "Closed": |
| click.echo("JIRA issue {} already has status '{}'".format( |
| jira_id, cur_status)) |
| return |
| |
| click.echo(click.style("\n === JIRA %s ===" % jira_id, bold=True)) |
| click.echo( |
| "summary:\t%s\nassignee:\t%s\nstatus:\t\t%s\nurl:\t\t%s/%s\n" % ( |
| cur_summary, cur_assignee, cur_status, JIRA_BASE, jira_id)) |
| if not click.confirm(click.style( |
| 'Proceed with {}?'.format(jira_id), fg='blue', bold=True)): |
| return |
| |
| if comment is None: |
| comment = click.prompt( |
| click.style( |
| 'Please enter a comment to explain why this issue ' |
| 'is being closed', |
| fg='blue', bold=True), |
| default='', |
| show_default=False) |
| |
| versions = asf_jira.project_versions("GOBBLIN") |
| versions = sorted(versions, key=lambda x: x.name, reverse=True) |
| versions = filter(lambda x: x.raw['released'] is False, versions) |
| # Consider only x.y.z versions |
| versions = list(filter( |
| lambda x: re.match('\d+\.\d+\.\d+', x.name), versions)) |
| |
| if versions: |
| default_fix_versions = map( |
| lambda x: fix_version_from_branch(x, versions), merge_branches) |
| default_fix_versions = [v.name for v in default_fix_versions if v] |
| else: |
| default_fix_versions = [] |
| |
| # TODO Gobblin versions vary from two to four decimal places (2.0, 1.7.1.3) |
| # The following logic can be reintroduced if/when a standard emerges. |
| |
| # for v in default_fix_versions: |
| |
| # Handles the case where we have forked a release branch but not yet |
| # made the release. In this case, if the PR is committed to the master |
| # branch and the release branch, we only consider the release branch to |
| # be the fix version. E.g. it is not valid to have both 1.1.0 and 1.0.0 |
| # as fix versions. |
| |
| # (major, minor, patch) = v.split(".") |
| # if patch == "0": |
| # previous = "%s.%s.%s" % (major, int(minor) - 1, 0) |
| # if previous in default_fix_versions: |
| # default_fix_versions = list(filter( |
| # lambda x: x != v, default_fix_versions)) |
| default_fix_versions = ",".join(default_fix_versions) |
| |
| fix_versions = click.prompt( |
| click.style( |
| "Enter comma-separated fix version(s)", fg='blue', bold=True), |
| default=default_fix_versions) |
| if fix_versions == "": |
| fix_versions = default_fix_versions |
| fix_versions = fix_versions.replace(" ", "").split(",") |
| if fix_versions == ['']: |
| fix_versions = None |
| |
| def get_version_json(version_str): |
| version_list = list(filter(lambda v: v.name == version_str, versions)) |
| if version_list: |
| return version_list[0].raw |
| else: |
| return '' |
| |
| if fix_versions and fix_versions != ['']: |
| jira_fix_versions = list(map(get_version_json, fix_versions)) |
| else: |
| jira_fix_versions = None |
| |
| action = list(filter( |
| lambda a: a['name'] == 'Resolve Issue', |
| asf_jira.transitions(jira_id)))[0] |
| resolution = list(filter( |
| lambda r: r.raw['name'] == "Fixed", |
| asf_jira.resolutions()))[0] |
| asf_jira.transition_issue( |
| jira_id, |
| action["id"], |
| fixVersions=jira_fix_versions, |
| comment=comment or None, |
| resolution = {'id': resolution.raw['id']}) |
| |
| click.echo("Successfully resolved {id}{fv}!".format( |
| id=jira_id, |
| fv=' with fix versions={}'.format(fix_versions) if fix_versions else '' |
| )) |
| |
| |
| def standardize_jira_ref(text, only_jira=False): |
| """ |
| Standardize the [GOBBLIN-XXXXX] [MODULE] prefix |
| Converts "[GOBBLIN-XXX][mllib] Issue", "[MLLib] GOBBLIN-XXX. Issue" or "GOBBLIN XXX [MLLIB]: Issue" to "[GOBBLIN-XXX][MLLIB] Issue" |
| |
| >>> standardize_jira_ref("[GOBBLIN-5821] [SQL] ParquetRelation2 CTAS should check if delete is successful") |
| '[GOBBLIN-5821][SQL] ParquetRelation2 CTAS should check if delete is successful' |
| >>> standardize_jira_ref("[GOBBLIN-4123][Project Infra][WIP]: Show new dependencies added in pull requests") |
| '[GOBBLIN-4123][PROJECT INFRA][WIP] Show new dependencies added in pull requests' |
| >>> standardize_jira_ref("[MLlib] Gobblin 5954: Top by key") |
| '[GOBBLIN-5954][MLLIB] Top by key' |
| >>> standardize_jira_ref("[GOBBLIN-979] a LRU scheduler for load balancing in TaskSchedulerImpl") |
| '[GOBBLIN-979] a LRU scheduler for load balancing in TaskSchedulerImpl' |
| >>> standardize_jira_ref("GOBBLIN-1094 Support MiMa for reporting binary compatibility accross versions.") |
| '[GOBBLIN-1094] Support MiMa for reporting binary compatibility accross versions.' |
| >>> standardize_jira_ref("[WIP] [GOBBLIN-1146] Vagrant support for Spark") |
| '[GOBBLIN-1146][WIP] Vagrant support for Spark' |
| >>> standardize_jira_ref("GOBBLIN-1032. If Yarn app fails before registering, app master stays aroun...") |
| '[GOBBLIN-1032] If Yarn app fails before registering, app master stays aroun...' |
| >>> standardize_jira_ref("[GOBBLIN-6250][GOBBLIN-6146][GOBBLIN-5911][SQL] Types are now reserved words in DDL parser.") |
| '[GOBBLIN-6250][GOBBLIN-6146][GOBBLIN-5911][SQL] Types are now reserved words in DDL parser.' |
| >>> standardize_jira_ref("Additional information for users building from source code") |
| 'Additional information for users building from source code' |
| >>> standardize_jira_ref('GOBBLIN 35 GOBBLIN--36 GOBBLIN 37 test', only_jira=True) |
| '[GOBBLIN-35][GOBBLIN-36][GOBBLIN-37]' |
| """ |
| jira_refs = [] |
| components = [] |
| |
| pattern = re.compile(r'([\[]*GOBBLIN[-\s]*[0-9]{1,6}[\]]*)', re.IGNORECASE) |
| for ref in pattern.findall(text): |
| |
| orig_ref = ref |
| if not re.findall("[GOBBLIN-[0-9]{1,6}]", ref): |
| # convert to uppercase |
| ref = ref.upper() |
| # replace 0+ spaces with a dash |
| ref = re.sub(r'(GOBBLIN)[-\s]*([0-9]{1,6})', r'\1-\2', ref.upper()) |
| |
| # and add brackets if needed |
| if not ref.startswith('['): |
| ref = '[' + ref |
| if not ref.endswith(']'): |
| ref = ref + ']' |
| |
| jira_refs.append(ref) |
| text = text.replace(orig_ref, '') |
| |
| # Extract Gobblin component(s): |
| # Look for alphanumeric chars, spaces, dashes, periods, and/or commas |
| pattern = re.compile(r'(\[[\w\s,-\.]+\])', re.IGNORECASE) |
| for component in pattern.findall(text): |
| components.append(component.upper()) |
| text = text.replace(component, '') |
| |
| # Cleanup any remaining symbols: |
| pattern = re.compile(r'^\W+(.*)', re.IGNORECASE) |
| if (pattern.search(text) is not None): |
| text = pattern.search(text).groups()[0] |
| |
| def unique(seq): |
| new_seq = [] |
| for s in seq: |
| if s not in new_seq: |
| new_seq.append(s) |
| return new_seq |
| |
| # Assemble full text (JIRA ref(s), module(s), remaining text) |
| clean_text = ''.join(unique(jira_refs)).strip() |
| if not only_jira: |
| clean_text += ( |
| ''.join(unique(components)).strip() + " " + text.strip()) |
| |
| # Replace multiple spaces with a single space, e.g. if no jira refs |
| # and/or components were included |
| clean_text = re.sub(r'\s+', ' ', clean_text.strip()) |
| |
| return clean_text |
| |
| |
| def get_current_ref(): |
| ref = run_cmd("git rev-parse --abbrev-ref HEAD") |
| if ref == 'HEAD': |
| # The current ref is a detached HEAD, so grab its SHA. |
| return run_cmd("git rev-parse HEAD") |
| else: |
| return ref |
| |
| |
| def main(pr_num, local=False): |
| """ |
| Utility for creating well-formed pull request merges and pushing them |
| to Apache. |
| |
| This tool assumes you already have a local Gobblin git folder and that you |
| have added remotes corresponding to both (i) the github apache Gobblin |
| mirror and (ii) the apache git repo. |
| |
| To configure the tool, set the following env vars: |
| - GOBBLIN_GIT |
| The location of your Gobblin git development area (defaults to the |
| current working directory) |
| |
| - GITHUB_REMOTE_NAME |
| GitHub remote name (defaults to "github") |
| |
| - APACHE_REMOTE_NAME |
| Apache git remote name (defaults to "apache") |
| |
| - JIRA_USERNAME |
| ASF JIRA username for automatically closing JIRA issues. Users will be |
| prompted if it is not set. |
| |
| - JIRA_PASSWORD |
| ASF JIRA password for automatically closing JIRA issues. Users will be |
| prompted if it is not set. |
| |
| - GITHUB_OAUTH_KEY |
| Only required if you are exceeding the rate limit for a single IP |
| address. |
| """ |
| global original_head |
| |
| os.chdir(GOBBLIN_GIT_LOCATION) |
| original_head = get_current_ref() |
| |
| branches = get_json("%s/branches" % GITHUB_API_BASE) |
| branch_names = filter( |
| lambda x: x.startswith("branch-"), [x['name'] for x in branches]) |
| # Assumes branch names can be sorted lexicographically |
| latest_branch = sorted(branch_names, reverse=True) |
| if latest_branch: |
| latest_branch = latest_branch[0] |
| else: |
| latest_branch = '' |
| |
| if not pr_num: |
| pr_num = click.prompt( |
| click.style( |
| "Please enter the number of the pull request you'd " |
| "like to work with", |
| fg='blue', bold=True), |
| type=int) |
| else: |
| click.echo('Working with pull request {}'.format(pr_num)) |
| |
| pr = get_json("{}/pulls/{}".format(GITHUB_API_BASE, pr_num)) |
| pr_events = get_json("{}/issues/{}/events".format(GITHUB_API_BASE, pr_num)) |
| |
| url = pr["url"] |
| |
| # Decide whether to use the modified title or not |
| modified_title = standardize_jira_ref(pr["title"]) |
| if modified_title != pr["title"]: |
| click.echo("I've re-written the title to match the standard format:") |
| click.echo("Original: %s" % pr["title"]) |
| click.echo("Modified: %s" % modified_title) |
| result = click.confirm(click.style( |
| "Would you like to use the modified title?", fg='blue', bold=True)) |
| if result: |
| title = modified_title |
| click.echo("Using modified title:") |
| else: |
| title = pr["title"] |
| click.echo("Using original title:") |
| click.echo(title) |
| else: |
| title = pr["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 are either closed or merged by asfgit |
| merge_commits = [ |
| e for e in pr_events |
| if e["actor"]["login"] == GITHUB_USER and |
| (e["event"] == "closed" or e["event"] == "merged")] |
| |
| if merge_commits and False: |
| merge_hash = merge_commits[0]["commit_id"] |
| message = get_json( |
| "%s/commits/%s" % (GITHUB_API_BASE, merge_hash) |
| )["commit"]["message"] |
| |
| continue_maybe( |
| "Pull request %s has already been merged. " |
| "Do you want to backport?" % pr_num) |
| commit_is_downloaded = run_cmd( |
| ['git', 'rev-parse', '--quiet', '--verify', |
| "%s^{commit}" % merge_hash]) != "" |
| if not commit_is_downloaded: |
| fail( |
| "Couldn't find any merge commit for #%s, " |
| "you may need to update HEAD." % pr_num) |
| |
| click.echo("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 {} is not mergeable in its current form.\n' |
| 'Continue anyway? (experts only!)'.format(pr_num)) |
| continue_maybe(msg) |
| |
| click.echo(click.style("\n=== Pull Request #%s ===" % pr_num, bold=True)) |
| click.echo("title:\t%s\nsource:\t%s\ntarget:\t%s\nurl:\t%s\n" % ( |
| title, pr_repo_desc, target_ref, url)) |
| continue_maybe("Proceed with pull request #{}?".format(pr_num)) |
| |
| merged_refs = [target_ref] |
| |
| merge_hash = merge_pr(pr_num, target_ref, title, body, pr_repo_desc, local) |
| |
| if local: |
| return |
| |
| msg = "Would you like to pick {} into another branch?".format(merge_hash) |
| while click.confirm(click.style(msg, fg='blue', bold=True)): |
| merged_refs = merged_refs + [ |
| cherry_pick(pr_num, merge_hash, latest_branch)] |
| |
| jira_ids = set(re.findall("GOBBLIN-[0-9]{1,6}", title + body) or [None]) |
| |
| msg = reflow( |
| """ |
| We found {n} JIRA issue{s} referenced in the PR. Would you |
| like to update{it}{them} any other JIRA issues? |
| """.format( |
| n=len(jira_ids) if jira_ids else 'no', |
| s='s' if len(jira_ids) != 1 else '', |
| it=' it or' if len(jira_ids) == 1 else '', |
| them=' them or' if len(jira_ids) > 1 else '')) |
| if not click.confirm(click.style(msg, fg='blue', bold=True), default=True): |
| fail("Okay, exiting.") |
| |
| jira_comment = "Issue resolved by pull request #{}\n[{}/{}]".format( |
| pr_num, GITHUB_BASE, pr_num) |
| |
| for jira_id in set(jira_ids): |
| resolve_jira_issue( |
| jira_id=jira_id, |
| comment=jira_comment, |
| merge_branches=merged_refs) |
| |
| if not jira_ids or click.confirm(click.style( |
| 'Would you like to resolve another JIRA issue?', |
| fg='blue', bold=True)): |
| resolve_jira_issues_loop( |
| comment=jira_comment, |
| merge_branches=merged_refs) |
| |
| |
| @click.group() |
| def cli(): |
| r""" |
| This tool should be used by Gobblin committers to test PRs, merge them |
| into the master branch, and close related JIRA issues. |
| |
| Before you begin, make sure you have created the 'apache' and 'github' git |
| remotes. You can use the "setup_git_remotes" command to do this |
| automatically. If you do not want to use these remote names, you can tell |
| the PR tool by setting the appropriate environment variables. For more |
| information, run: |
| |
| gobblin-pr merge --help |
| """ |
| os.chdir(GOBBLIN_GIT_LOCATION) |
| click.echo(GOBBLIN_GIT_LOCATION) |
| status = run_cmd('git status --porcelain', echo_cmd=False) |
| if status: |
| msg = ( |
| 'You have uncomitted changes in this branch. Running this tool\n' |
| 'will delete them permanently. Continue?') |
| if click.confirm(click.style(msg, fg='red', bold=True)): |
| run_cmd('git reset --hard', echo_cmd=False) |
| else: |
| sys.exit(-1) |
| |
| |
| @cli.command(short_help='Merge a GitHub PR into Gobblin master') |
| @click.argument('pr_num', default=0) |
| def merge(pr_num): |
| """ |
| Utility for creating well-formed pull request merges and pushing them |
| to Apache, as well as closing JIRA issues. |
| |
| This tool assumes you already have a local Gobblin git folder and that you |
| have added remotes corresponding to both (i) the github apache Gobblin |
| mirror and (ii) the apache git repo. |
| |
| To configure the tool, set the following env vars: |
| - GOBBLIN_GIT |
| The location of your Gobblin git development area (defaults to the |
| current working directory) |
| |
| - GITHUB_REMOTE_NAME |
| GitHub remote name (defaults to "github") |
| |
| - APACHE_REMOTE_NAME |
| Apache git remote name (defaults to "apache") |
| |
| - JIRA_USERNAME |
| ASF JIRA username for automatically closing JIRA issues. Users will be |
| prompted if it is not set. |
| |
| - JIRA_PASSWORD |
| ASF JIRA password for automatically closing JIRA issues. Users will be |
| prompted if it is not set. |
| |
| - GITHUB_OAUTH_KEY |
| Only required if you are exceeding the rate limit for a single IP |
| address. |
| """ |
| main(pr_num, local=False) |
| |
| |
| @cli.command(short_help='Clone a GitHub PR locally for testing (no push)') |
| @click.argument('pr_num', default=0) |
| def work_local(pr_num): |
| """ |
| Clones a PR locally for testing, imitating a full merge workflow, but does |
| not push the changes to Gobblin master. Instead, the program will pause |
| once the local merge is complete, allowing the user to explore any changes. |
| Once finished, the program will delete the merge and restore the original |
| environment. |
| """ |
| main(pr_num, local=True) |
| |
| |
| @cli.command(short_help='Close a JIRA issue (without merging a PR)') |
| def close_jira(): |
| """ |
| This command runs only the JIRA part of the PR tool; it doesn't do any |
| merging at all. |
| """ |
| resolve_jira_issues_loop() |
| |
| |
| @cli.command(short_help='Set up default git remotes') |
| def setup_git_remotes(): |
| click.echo(reflow(""" |
| This command will create git remotes to mirror the following |
| structure. If you do not want to use these names, you must set the |
| GITHUB_REMOTE_NAME and APACHE_REMOTE_NAME environment variables: |
| |
| git remote -v |
| apache https://git-wip-us.apache.org/repos/asf/gobblin.git (fetch) |
| apache https://git-wip-us.apache.org/repos/asf/gobblin.git (push) |
| github https://github.com/apache/gobblin.git (fetch) |
| github https://github.com/apache/gobblin.git (push) |
| |
| If these remotes already exist, the tool will display an error. |
| """)) |
| continue_maybe('Do you want to continue?') |
| |
| error = False |
| try: |
| run_cmd('git remote add apache https://git-wip-us.apache.org/repos/asf/gobblin.git') |
| except: |
| click.echo(click.style(reflow( |
| '>>ERROR: Could not create apache remote. If it already exists, ' |
| 'run `git remote remove apache` to delete it.', fg='red'))) |
| error = True |
| try: |
| run_cmd('git remote add github https://github.com/apache/gobblin.git') |
| except: |
| click.echo(click.style(reflow( |
| '>>ERROR: Could not create github remote. If it already exists, ' |
| 'run `git remote remove github` to delete it.', fg='red'))) |
| error = True |
| click.echo('Done setting up git remotes. Run git remote -v to see them.') |
| |
| |
| if __name__ == "__main__": |
| import doctest |
| (failure_count, test_count) = doctest.testmod() |
| if failure_count: |
| exit(-1) |
| try: |
| cli() |
| except: |
| clean_up() |
| raise |