diff --git a/README.md b/README.md index d9950c5..0293bdf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # Gerrit-to-Github-Issues -Set of scripts that can be used to synchronize/update Github Issues with data from a Gerrit instance +Set of scripts that can be used to synchronize/update Github Issues with data from a Gerrit instance. diff --git a/gerrit_to_github_issues/__main__.py b/gerrit_to_github_issues/__main__.py index caab4d8..b59845a 100644 --- a/gerrit_to_github_issues/__main__.py +++ b/gerrit_to_github_issues/__main__.py @@ -1,58 +1,64 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); +# Licensed 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, +# 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. +import argparse import logging -import re -import sys +import os -import github +import errors +from engine import update + +LOG_FORMAT = '%(asctime)s %(levelname)-8s %(name)s:' \ + '%(funcName)s [%(lineno)3d] %(message)s' # noqa + +LOG = logging.getLogger(__name__) + + +def validate(namespace: argparse.Namespace): + arg_dict = vars(namespace) + if not ((arg_dict['github_username'] and arg_dict['github_password']) or arg_dict['github_token']): + raise errors.GithubConfigurationError + return arg_dict -GH_USER = sys.argv[1] -GH_PW = sys.argv[2] -ZUUL_MESSAGE = sys.argv[3] -GERRIT_URL = sys.argv[4] -REPO_NAME = 'airshipit/airshipctl' if __name__ == '__main__': - issue_number = re.search(r'(?<=\[#)(.*?)(?=\])', ZUUL_MESSAGE).group(0) - gh = github.Github(GH_USER, GH_PW) - repo = gh.get_repo(REPO_NAME) - issue = repo.get_issue(number=int(issue_number)) - comment_msg = '' - link_exists = False - for comment in issue.get_comments(): - if GERRIT_URL in comment.body: - logging.log(logging.INFO, 'Gerrit link has already been posted') - link_exists = True - if issue.state == 'closed' and not link_exists: - issue.edit(state='open') - comment_msg += 'Issue reopened due to new activity on Gerrit.\n\n' - if 'WIP' in ZUUL_MESSAGE.upper() or 'DNM' in ZUUL_MESSAGE.upper(): - logging.log(logging.INFO, 'Changing status with `wip` label') - issue.add_to_labels('wip') - try: - issue.remove_from_labels('ready for review') - except github.GithubException: - logging.log(logging.DEBUG, 'Could not remove `ready for review` label, ' - 'it probably was not on the issue') - else: - logging.log(logging.INFO, 'Changing status with `ready for review` label') - issue.add_to_labels('ready for review') - try: - issue.remove_from_labels('wip') - except github.GithubException: - logging.log(logging.DEBUG, 'Could not remove `wip` label, ' - 'it probably was not on the issue') - if not link_exists: - comment_msg += f'New Related Change: {GERRIT_URL}' - if comment_msg: - issue.create_comment(comment_msg) - logging.log(logging.INFO, f'Comment posted to issue #{issue_number}') \ No newline at end of file + parser = argparse.ArgumentParser( + prog='gerrit-to-github-issues', + usage='synchronizes GitHub Issues with new changes found in Gerrit', + description='This script evaluates the following logic on open changes from Gerrit:\n' + '1. Check for and extract an issue tag (i.e. "[#3]") from the open change\'s commit message.\n' + '2. Check associated Github Issue for a link to the change. If no such link exists, comment it.\n' + '3. If the associated issue was closed, re-open it and comment on it describing why it was ' + 're-opened and a link to the Gerrit change that was found.\n' + '4. If the Gerrit change\'s commit message contains a "WIP" or "DNM" tag, add the "wip" label and ' + 'to the issue remove other process labels such as "ready for review".\n' + '5. If no "WIP" or "DNM" tag is found in the change\'s commit message, add the "ready for review" ' + 'label to the issue and remove other process labels such as "ready for review".' + ) + parser.add_argument('-g', '--gerrit-url', action='store', required=True, type=str, + default=os.getenv('GERRIT_URL', default=None), help='Target Gerrit URL.') + parser.add_argument('-u', '--github-username', action='store', required=False, type=str, + default=os.getenv('GITHUB_USER', default=None), + help='Username to use for GitHub Issues integration. Defaults to GITHUB_USER in ' + 'environmental variables. Must be used with a password.') + parser.add_argument('-p', '--github-password', action='store', required=False, type=str, + default=os.getenv('GITHUB_PW', default=None), + help='Password to use for GitHub Issues integration. Defaults to GITHUB_PW in ' + 'environmental variables. Must be used with a username.') + parser.add_argument('-t', '--github-token', action='store', required=False, type=str, + default=os.getenv('GITHUB_TOKEN', default=None), + help='Token to use for GitHub Issues integration. Defaults to GITHUB_TOKEN in ' + 'environmental variables. This will be preferred over a username/password.') + parser.add_argument('gerrit_project_name', action='store', type=str, help='Target Gerrit project.') + parser.add_argument('github_project_name', action='store', type=str, help='Target Github project.') + ns = parser.parse_args() + args = validate(ns) + update(**args) diff --git a/gerrit_to_github_issues/engine.py b/gerrit_to_github_issues/engine.py new file mode 100644 index 0000000..da6747f --- /dev/null +++ b/gerrit_to_github_issues/engine.py @@ -0,0 +1,68 @@ +# Licensed 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. +import logging + +import github +from github.Repository import Repository + +import gerrit +import github_issues + +LOG = logging.getLogger(__name__) + + +def update(gerrit_url: str, gerrit_project_name: str, github_project_name: str, github_user: str, github_pw: str, + github_token: str): + repo = github_issues.get_repo(github_project_name, github_user, github_pw, github_token) + change_list = gerrit.get_changes(gerrit_url, gerrit_project_name) + for change in change_list: + if 'commitMessage' in change: + process_change(change, repo, gerrit_url) + + +def process_change(change: dict, repo: Repository, gerrit_url: str): + issue_number = github_issues.parse_issue_number(change['commitMessage']) + if not issue_number: + LOG.warning(f'No issue tag found for change #{change["number"]}') + return + try: + issue = repo.get_issue(issue_number) + except github.GithubException: + LOG.warning(f'Issue #{issue_number} not found for project') + return + comment_msg = '' + change_url = gerrit.make_gerrit_url(gerrit_url, change['number']) + link_exists = github_issues.check_issue_for_matching_comments(issue, change_url) + if issue.state == 'closed' and not link_exists: + issue.edit(state='open') + comment_msg += 'Issue reopened due to new activity on Gerrit.\n\n' + if 'WIP' in change['commitMessage'] or 'DNM' in change['commitMessage']: + LOG.debug(f'add `wip` to {issue_number}') + #issue.add_to_labels('wip') + try: + LOG.debug(f'rm `ready for review` to {issue_number}') + #issue.remove_from_labels('ready for review') + except github.GithubException: + LOG.debug(f'`ready for review` tag does not exist on issue #{issue_number}') + else: + LOG.debug(f'add `ready for review` to {issue_number}') + #issue.add_to_labels('ready for review') + try: + LOG.debug(f'rm `wip` to {issue_number}') + #issue.remove_from_labels('wip') + except github.GithubException: + LOG.debug(f'`wip` tag does not exist on issue #{issue_number}') + if not link_exists: + comment_msg += f'New Related Change: {gerrit_url}' + if comment_msg: + #issue.create_comment(comment_msg) + LOG.info(f'Comment posted to issue #{gerrit_url}') diff --git a/gerrit_to_github_issues/errors.py b/gerrit_to_github_issues/errors.py new file mode 100644 index 0000000..76e6510 --- /dev/null +++ b/gerrit_to_github_issues/errors.py @@ -0,0 +1,19 @@ +# Licensed 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. + + +class GithubConfigurationError(Exception): + message = 'No Github username/password or token has been defined or they are invalid.' + + +class GerritConfigurationError(Exception): + message = 'No Gerrit URL defined.' diff --git a/gerrit_to_github_issues/gerrit.py b/gerrit_to_github_issues/gerrit.py new file mode 100644 index 0000000..94d1a0e --- /dev/null +++ b/gerrit_to_github_issues/gerrit.py @@ -0,0 +1,26 @@ +# Licensed 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. +import json + +from fabric import Connection + + +def get_changes(gerrit_url: str, project_name: str, port: int = 29418) -> list: + cmd = f'gerrit query --format=JSON status:open project:{project_name}' + result = Connection(gerrit_url, port=port).run(cmd) + processed_stdout = '{"data":[%s]}' % ','.join(list(filter(None, result.stdout.split('\n')))) + data = json.loads(processed_stdout) + return data + + +def make_gerrit_url(gerrit_url: str, change_number: str, protocol: str = 'https'): + return f'{protocol}://{gerrit_url}/{change_number}' diff --git a/gerrit_to_github_issues/github_issues.py b/gerrit_to_github_issues/github_issues.py new file mode 100644 index 0000000..cd5f927 --- /dev/null +++ b/gerrit_to_github_issues/github_issues.py @@ -0,0 +1,42 @@ +# Licensed 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. +import re + +import github +from github.Issue import Issue +from github.Repository import Repository + +import errors + + +def parse_issue_number(commit_msg: str) -> int: + match = re.search(r'(?<=\[#)(.*?)(?=\])', commit_msg) + if not match: + return None + return int(match.group(0)) + + +def get_repo(repo_name: str, github_user: str, github_pw: str, github_token: str) -> Repository: + if github_token: + gh = github.Github(github_token) + elif github_user and github_pw: + gh = github.Github(github_user, github_pw) + else: + raise errors.GithubConfigurationError + return gh.get_repo(repo_name) + + +def check_issue_for_matching_comments(issue: Issue, contains: str) -> bool: + for comment in issue.get_comments(): + if contains in comment.body: + return True + return False diff --git a/requirements.txt b/requirements.txt index f61ef2a..dd4656b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -PyGithub==1.46 \ No newline at end of file +PyGithub==1.46 +fabric==2.5.0 \ No newline at end of file