diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 929dc78..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Lint code in the repo - -on: - push: - -jobs: - shellcheck: - name: Shellcheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master diff --git a/.github/workflows/test_jira_sync.yml b/.github/workflows/test_jira_sync.yml index ebd9b4b..b5b9eaf 100644 --- a/.github/workflows/test_jira_sync.yml +++ b/.github/workflows/test_jira_sync.yml @@ -11,6 +11,6 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Test JIRA sync - uses: ./sync_issues_to_jira + uses: . with: - entrypoint: ./sync_issues_to_jira/test_sync_to_jira.py + entrypoint: ./test_sync_to_jira.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2b42df --- /dev/null +++ b/.gitignore @@ -0,0 +1,106 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# cache +.mypy_cache/ +.ruff_cache/ +__pycache__/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..dddd8b9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,51 @@ +# Run `pre-commit autoupdate` to update to the latest pre-commit hooks version. +# When changing the version of tools that are also installed as development dependencies (e.g., black, mypy, ruff), +# please ensure the same versions are pinned in this file as in `pyproject.toml`. +--- +minimum_pre_commit_version: 3.3.0 # Specifies the minimum version of pre-commit required for this configuration +default_install_hook_types: [pre-commit, commit-msg] # Default hook types to install if not specified in individual hooks +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace # Removes trailing whitespaces from lines + - id: end-of-file-fixer # Ensures files end with a newline + - id: check-executables-have-shebangs # Checks executables have a proper shebang + - id: mixed-line-ending # Detects mixed line endings (CRLF/LF) + args: ['-f=lf'] # Forces files to use LF line endings + - id: double-quote-string-fixer # Converts single quotes to double quotes in strings + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.280 + hooks: + - id: ruff # Runs ruff linter (replaces flake8) + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/asottile/reorder_python_imports + rev: v3.12.0 + hooks: + - id: reorder-python-imports # Reorders Python imports to a standard format (replaces isort) + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.4.1 + hooks: + - id: mypy # Runs mypy for Python type checking + additional_dependencies: ['types-all'] + + - repo: https://github.com/espressif/conventional-precommit-linter + rev: v1.6.0 + hooks: + - id: conventional-precommit-linter # Lints commit messages for conventional format + stages: [commit-msg] + + - repo: https://github.com/psf/black + rev: '23.7.0' + hooks: + - id: black # Formats Python code using black + + - repo: https://github.com/pylint-dev/pylint + rev: v2.17.5 + hooks: + - id: pylint # Runs pylint on Python code diff --git a/Dockerfile b/Dockerfile index eeedf96..6409f4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,29 @@ -FROM node:current-bullseye-slim +FROM node:18-bullseye-slim +# Setting environment variables ENV LANG C.UTF-8 ENV LC_ALL C.UTF-8 -ADD requirements.txt /tmp/requirements.txt +# Install Python and pip +RUN : \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + python3-pip \ + python3-setuptools \ + && pip3 install --no-cache-dir --upgrade pip \ + && rm -rf /var/lib/apt/lists/* \ + && : -RUN apt-get update \ - && apt-get install -y python3-pip \ - && pip3 install --upgrade pip \ - && pip3 install -r /tmp/requirements.txt - -RUN rm /tmp/requirements.txt +# Install Python dependencies +COPY requirements.txt /tmp/requirements.txt +RUN pip3 install --no-cache-dir -r /tmp/requirements.txt +# Install Node.js dependencies RUN npm i -g @shogobg/markdown2confluence@0.1.6 -ADD sync_issue.py /sync_issue.py -ADD sync_pr.py /sync_pr.py -ADD sync_to_jira.py /sync_to_jira.py -ADD test_sync_to_jira.py /test_sync_to_jira.py +# Copy Python scripts +WORKDIR /app +COPY sync_issue.py sync_pr.py sync_to_jira.py test_sync_to_jira.py / +# Define the entrypoint ENTRYPOINT ["/usr/bin/python3", "/sync_to_jira.py"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ad2e04 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[tool.black] + line-length = 120 # The maximum line length for Python code formatting + skip-string-normalization = true # Avoids converting single quotes to double quotes in strings (pre-commit hook enforces single quotes in Python code) + +[tool.ruff] + line-length = 120 # Specifies the maximum line length for ruff checks + select = ['E', 'F', 'W'] # Types of issues ruff should check for + target-version = "py38" # Specifies the target Python version for ruff checks + +[tool.mypy] + disallow_incomplete_defs = false # Disallows defining functions with incomplete type annotations + disallow_untyped_defs = false # Disallows defining functions without type annotations or with incomplete type annotations + exclude = '^venv/' # Paths to ignore during type checking + ignore_missing_imports = true # Suppress error messages about imports that cannot be resolved + python_version = "3.8" # Specifies the Python version used to parse and check the target program + warn_no_return = true # Shows errors for missing return statements on some execution paths + warn_return_any = true # Shows a warning when returning a value with type Any from a function declared with a non- Any return type + +[tool.pylint] + [tool.pylint.MASTER] + ignore-paths = ["tests/.*"] # Paths to ignore during linting + [tool.pylint.'BASIC'] + variable-rgx = "[a-z_][a-z0-9_]{1,30}$" # Variable names must start with a lowercase letter or underscore, followed by any combination of lowercase letters, numbers, or underscores, with a total length of 2 to 30 characters. + [tool.pylint.'MESSAGES CONTROL'] + disable = [ + "duplicate-code", # R0801: Similar lines in %s files + "fixme", # W0511: Used when TODO/FIXME is encountered + "import-error", # E0401: Used when pylint has been unable to import a module + "import-outside-toplevel", # E0402: Imports should usually be on top of the module + "logging-fstring-interpolation", # W1202: Use % formatting in logging functions and pass the % parameters as arguments + "missing-class-docstring", # C0115: Missing class docstring + "missing-function-docstring", # C0116: Missing function or method docstring + "missing-module-docstring", # C0114: Missing module docstring + "no-name-in-module", # W0611: Used when a name cannot be found in a module + "too-few-public-methods", # R0903: Too few public methods of class + "too-many-branches", # R0912: Too many branches + "too-many-locals", # R0914: Too many local variables + "too-many-return-statements", # R0911: Too many return statements + "too-many-statements", # R0915: Too many statements + "ungrouped-imports", # C0412: Imports should be grouped by packages + ] + [tool.pylint.'FORMAT'] + max-line-length = 120 # Specifies the maximum line length for pylint checks diff --git a/requirements.txt b/requirements.txt index a02bf1b..b391798 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -jira>=3.0.1<3.1.0 -PyGithub>=1.55<1.56 +jira>=3.0.1,<3.1.0 +PyGithub>=1.55,<1.56 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 46e8615..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -ignore = -max-line-length = 160 - diff --git a/sync_issue.py b/sync_issue.py index 441fb9d..3d3be0a 100755 --- a/sync_issue.py +++ b/sync_issue.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2019 Espressif Systems (Shanghai) PTE LTD +# Copyright 2019-2024 Espressif Systems (Shanghai) PTE LTD # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,30 +14,29 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from jira import JIRA, JIRAError -from github import Github -from github.GithubException import GithubException -import json import os import random import re import subprocess -import sys import tempfile import time +from github import Github +from github.GithubException import GithubException +from jira import JIRAError + # 10101 is ID for New Feature issue type in Jira. JIRA_NEW_FEATURE_TYPE_ID = 10101 # 10004 is ID for Bug issue type in Jira. JIRA_BUG_TYPE_ID = 10004 # Initialize GitHub instance -GITHUB = Github(os.environ["GITHUB_TOKEN"]) +GITHUB = Github(os.environ['GITHUB_TOKEN']) # Initialize GitHub repository REPO = GITHUB.get_repo(os.environ['GITHUB_REPOSITORY']) def handle_issue_opened(jira, event): - gh_issue = event["issue"] + gh_issue = event['issue'] issue = _find_jira_issue(jira, gh_issue, False) if issue is not None: @@ -45,16 +44,16 @@ def handle_issue_opened(jira, event): return print('Creating new JIRA issue for new GitHub issue') - _create_jira_issue(jira, event["issue"]) + _create_jira_issue(jira, event['issue']) def handle_issue_edited(jira, event): - gh_issue = event["issue"] + gh_issue = event['issue'] issue = _find_jira_issue(jira, gh_issue, True) fields = { - "description": _get_description(gh_issue), - "summary": _get_summary(gh_issue), + 'description': _get_description(gh_issue), + 'summary': _get_summary(gh_issue), } _update_components_field(jira, fields, issue) @@ -63,85 +62,85 @@ def handle_issue_edited(jira, event): _update_link_resolved(jira, gh_issue, issue) - _leave_jira_issue_comment(jira, event, "edited", True, jira_issue=issue) + _leave_jira_issue_comment(jira, event, 'edited', True, jira_issue=issue) def handle_issue_closed(jira, event): # note: Not auto-closing the synced JIRA issue because GitHub # issues often get closed for the wrong reasons - ie the user # found a workaround but the root cause still exists. - issue = _leave_jira_issue_comment(jira, event, "closed", False) - try : + issue = _leave_jira_issue_comment(jira, event, 'closed', False) + try: # Sets value of custom GitHub Issue field to Closed issue.update(fields={'customfield_12100': {'value': 'Closed'}}) except JIRAError as error: print(f'Could not set GitHub Issue field to Closed when closing issue with error: {error}') if issue is not None: - _update_link_resolved(jira, event["issue"], issue) + _update_link_resolved(jira, event['issue'], issue) def handle_issue_labeled(jira, event): - gh_issue = event["issue"] - jira_issue = _find_jira_issue(jira, gh_issue, gh_issue["state"] == "open") + gh_issue = event['issue'] + jira_issue = _find_jira_issue(jira, gh_issue, gh_issue['state'] == 'open') if jira_issue is None: return labels = list(jira_issue.fields.labels) - new_label = _get_jira_label(event["label"]) + new_label = _get_jira_label(event['label']) if _check_issue_label(new_label) is None: return if new_label not in labels: labels.append(new_label) - jira_issue.update(fields={"labels": labels}) + jira_issue.update(fields={'labels': labels}) def handle_issue_unlabeled(jira, event): - gh_issue = event["issue"] - jira_issue = _find_jira_issue(jira, gh_issue, gh_issue["state"] == "open") + gh_issue = event['issue'] + jira_issue = _find_jira_issue(jira, gh_issue, gh_issue['state'] == 'open') if jira_issue is None: return labels = list(jira_issue.fields.labels) - removed_label = _get_jira_label(event["label"]) + removed_label = _get_jira_label(event['label']) if _check_issue_label(removed_label) is None: return try: labels.remove(removed_label) - jira_issue.update(fields={"labels": labels}) + jira_issue.update(fields={'labels': labels}) except ValueError: pass # not in labels list def handle_issue_deleted(jira, event): - _leave_jira_issue_comment(jira, event, "deleted", False) + _leave_jira_issue_comment(jira, event, 'deleted', False) def handle_issue_reopened(jira, event): - issue = _leave_jira_issue_comment(jira, event, "reopened", True) - try : - # Sets value of custom GitHub Issue field to Open + issue = _leave_jira_issue_comment(jira, event, 'reopened', True) + try: + # Sets value of custom GitHub Issue field to Open issue.update(fields={'customfield_12100': {'value': 'Open'}}) except JIRAError as error: print(f'Could not set GitHub Issue field to Open when reopening issue with error: {error}') - _update_link_resolved(jira, event["issue"], issue) + _update_link_resolved(jira, event['issue'], issue) def handle_comment_created(jira, event): - gh_comment = event["comment"] + gh_comment = event['comment'] - jira_issue = _find_jira_issue(jira, event["issue"], True) + jira_issue = _find_jira_issue(jira, event['issue'], True) jira.add_comment(jira_issue.id, _get_jira_comment_body(gh_comment)) def handle_comment_edited(jira, event): - gh_comment = event["comment"] - old_gh_body = _markdown2wiki(event["changes"]["body"]["from"]) + gh_comment = event['comment'] + old_gh_body = _markdown2wiki(event['changes']['body']['from']) - jira_issue = _find_jira_issue(jira, event["issue"], True) + jira_issue = _find_jira_issue(jira, event['issue'], True) # Look for the old comment and update it if we find it old_jira_body = _get_jira_comment_body(gh_comment, old_gh_body) @@ -157,10 +156,10 @@ def handle_comment_edited(jira, event): def handle_comment_deleted(jira, event): - gh_comment = event["comment"] - jira_issue = _find_jira_issue(jira, event["issue"], True) + gh_comment = event['comment'] + jira_issue = _find_jira_issue(jira, event['issue'], True) jira.add_comment( - jira_issue.id, "@%s deleted [GitHub issue comment|%s]" % (gh_comment["user"]["login"], gh_comment["html_url"]) + jira_issue.id, f"@{gh_comment['user']['login']} deleted [GitHub issue comment|{gh_comment['html_url']}]" ) @@ -168,7 +167,7 @@ def handle_comment_deleted(jira, event): def sync_issues_manually(jira, event): # Get issue numbers that were entered manually when triggering workflow issue_numbers = event['inputs']['issue-numbers'] - issues = re.split('\W+', issue_numbers) + issues = re.split(r'\W+', issue_numbers) # Process every issue for issue_number in issues: if not issue_number.isnumeric(): @@ -185,7 +184,7 @@ def _check_issue_label(label): Ignore labels that start with "Status:" and "Resolution:". These labels are mirrored from Jira issue and should not be mirrored back as labels """ - ignore_prefix = ("status:", "resolution:") + ignore_prefix = ('status:', 'resolution:') if label.lower().startswith(ignore_prefix): return None @@ -201,12 +200,12 @@ def _update_link_resolved(jira, gh_issue, jira_issue): Also updates the link title, if GitHub issue title has changed. """ - resolved = gh_issue["state"] != "open" + resolved = gh_issue['state'] != 'open' for link in jira.remote_links(jira_issue): - if hasattr(link, "globalId") and link.globalId == gh_issue["html_url"]: - new_link = dict(link.raw["object"]) # RemoteLink update() requires all fields as a JSON object, it seems - new_link["title"] = gh_issue["title"] - new_link["status"]["resolved"] = resolved + if hasattr(link, 'globalId') and link.globalId == gh_issue['html_url']: + new_link = dict(link.raw['object']) # RemoteLink update() requires all fields as a JSON object, it seems + new_link['title'] = gh_issue['title'] + new_link['status']['resolved'] = resolved link.update(new_link, globalId=link.globalId, relationship=link.relationship) @@ -215,24 +214,24 @@ def _markdown2wiki(markdown): Convert markdown to JIRA wiki format. Uses https://github.com/Shogobg/markdown2confluence """ if markdown is None: - return "\n" # Allow empty/blank input + return '\n' # Allow empty/blank input with tempfile.TemporaryDirectory() as tmp_dir: md_path = os.path.join(tmp_dir, 'markdown.md') conf_path = os.path.join(tmp_dir, 'confluence.txt') - with open(md_path, 'w') as mdf: + with open(md_path, 'w', encoding='utf-8') as mdf: mdf.write(markdown) if not markdown.endswith('\n'): mdf.write('\n') try: subprocess.check_call(['markdown2confluence', md_path, conf_path]) - with open(conf_path, 'r') as f: - result = f.read() + with open(conf_path, 'r', encoding='utf-8') as file: + result = file.read() if len(result) > 16384: # limit any single body of text to 16KB (JIRA API limits total text to 32KB) - result = result[:16376] + "\n\n[...]" # add newlines to encourage end of any formatting blocks + result = result[:16376] + '\n\n[...]' # add newlines to encourage end of any formatting blocks return result - except subprocess.CalledProcessError as e: - print("Failed to run markdown2confluence: %s. JIRA issue will have raw Markdown contents." % e) + except subprocess.CalledProcessError as error: + print(f'Failed to run markdown2confluence: {error}. JIRA issue will have raw Markdown contents.') return markdown @@ -240,7 +239,7 @@ def _get_description(gh_issue): """ Return the JIRA description text that corresponds to the provided GitHub issue. """ - is_pr = "pull_request" in gh_issue + is_pr = 'pull_request' in gh_issue description_format = """ [GitHub %(type)s|%(github_url)s] from user @%(github_user)s: @@ -254,7 +253,7 @@ def _get_description(gh_issue): * Do not edit this description text, it may be updated automatically. * Please interact on GitHub where possible, changes will sync to here. """ - description_format = description_format.lstrip("\n") + description_format = description_format.lstrip('\n') if not is_pr: # additional dot point only shown for issues not PRs @@ -267,10 +266,10 @@ def _get_description(gh_issue): """ return description_format % { - "type": "Pull Request" if is_pr else "Issue", - "github_url": gh_issue["html_url"], - "github_user": gh_issue["user"]["login"], - "github_description": _markdown2wiki(gh_issue["body"]), + 'type': 'Pull Request' if is_pr else 'Issue', + 'github_url': gh_issue['html_url'], + 'github_user': gh_issue['user']['login'], + 'github_description': _markdown2wiki(gh_issue['body']), } @@ -280,12 +279,12 @@ def _get_summary(gh_issue): Format is: GH/PR #: """ - is_pr = "pull_request" in gh_issue - result = "%s #%d: %s" % ("PR" if is_pr else "GH", gh_issue["number"], gh_issue["title"]) + is_pr = 'pull_request' in gh_issue + result = f"{'PR' if is_pr else 'GH'} #{gh_issue['number']}: {gh_issue['title']}" # don't mirror any existing JIRA slug-like pattern from GH title to JIRA summary # (note we don't look for a particular pattern as the JIRA issue may have moved) - result = re.sub(r" \([\w]+-[\d]+\)", "", result) + result = re.sub(r' \([\w]+-[\d]+\)', '', result) return result @@ -299,25 +298,25 @@ def _create_jira_issue(jira, gh_issue): issuetype = os.environ.get('JIRA_ISSUE_TYPE', 'Task') fields = { - "summary": _get_summary(gh_issue), - "project": os.environ['JIRA_PROJECT'], - "description": _get_description(gh_issue), - "issuetype": issuetype, - "labels": [_get_jira_label(l) for l in gh_issue["labels"]], + 'summary': _get_summary(gh_issue), + 'project': os.environ['JIRA_PROJECT'], + 'description': _get_description(gh_issue), + 'issuetype': issuetype, + 'labels': [_get_jira_label(lbl) for lbl in gh_issue['labels']], } _update_components_field(jira, fields, None) issue = jira.create_issue(fields) - - try : - # Sets value of custom GitHub Issue field to Open + + try: + # Sets value of custom GitHub Issue field to Open issue.update(fields={'customfield_12100': {'value': 'Open'}}) except JIRAError as error: print(f'Could not set GitHub Issue field to Open when creating new issue with error: {error}') _add_remote_link(jira, issue, gh_issue) _update_github_with_jira_key(gh_issue, issue) - if gh_issue["state"] != "open": + if gh_issue['state'] != 'open': # mark the link to GitHub as resolved _update_link_resolved(jira, gh_issue, issue) @@ -328,15 +327,15 @@ def _add_remote_link(jira, issue, gh_issue): """ Add the JIRA "remote link" field that points to the issue """ - gh_url = gh_issue["html_url"] + gh_url = gh_issue['html_url'] jira.add_remote_link( issue=issue, destination={ - "url": gh_url, - "title": gh_issue["title"], + 'url': gh_url, + 'title': gh_issue['title'], }, globalId=gh_url, # globalId is always the GitHub URL - relationship="synced from", + relationship='synced from', ) @@ -345,17 +344,17 @@ def _update_github_with_jira_key(gh_issue, jira_issue): (updates made by github actions don't trigger new actions) """ - api_gh_issue = REPO.get_issue(gh_issue["number"]) + api_gh_issue = REPO.get_issue(gh_issue['number']) retries = 5 while True: try: - api_gh_issue.edit(title="%s (%s)" % (api_gh_issue.title, jira_issue.key)) + api_gh_issue.edit(title=f'{api_gh_issue.title} ({jira_issue.key})') break - except GithubException as e: + except GithubException as error: if retries == 0: raise - print("GitHub edit failed: %s (%d retries)" % (e, retries)) + print(f'GitHub edit failed: {error} ({retries} retries)') time.sleep(random.randrange(1, 5)) retries -= 1 @@ -373,8 +372,8 @@ def _update_components_field(jira, fields, existing_issue=None): fatal errors (especially likely if the JIRA issue has been moved between projects) . """ component = os.environ.get('JIRA_COMPONENT', '') - if not len(component): - print("No JIRA_COMPONENT variable set, not updating components field") + if not component: + print('No JIRA_COMPONENT variable set, not updating components field') return if existing_issue: @@ -388,14 +387,14 @@ def _update_components_field(jira, fields, existing_issue=None): print("JIRA project doesn't contain the configured component, not updating components field") return - print("Setting components field") + print('Setting components field') - fields["components"] = [{"name": component}] + fields['components'] = [{'name': component}] # keep any existing components as well if existing_issue: for component in existing_issue.fields.components: if component.name != component: - fields["components"].append({"name": component.name}) + fields['components'].append({'name': component.name}) def _get_jira_issue_type(jira, gh_issue): @@ -406,7 +405,7 @@ def _get_jira_issue_type(jira, gh_issue): NOTE: This is only suitable for setting on new issues. Changing issue type is unsafe. See https://jira.atlassian.com/browse/JRACLOUD-68207 """ - gh_labels = [l["name"] for l in gh_issue["labels"]] + gh_labels = [lbl['name'] for lbl in gh_issue['labels']] issue_types = jira.issue_types() @@ -414,18 +413,18 @@ def _get_jira_issue_type(jira, gh_issue): # Type: Feature Request label should match New Feature issue type in Jira if gh_label == 'Type: Feature Request': print('GitHub label is \'Type: Feature Request\'. Mapping to New Feature Jira issue type') - return {"id": JIRA_NEW_FEATURE_TYPE_ID} # JIRA API needs JSON here + return {'id': JIRA_NEW_FEATURE_TYPE_ID} # JIRA API needs JSON here # Some projects use Label with bug icon represented by ":bug:" in label name. # This if mathes those to Bug Jira issue type if gh_label == 'Type: Bug :bug:': print('GitHub label is \'Type: Bug :bug:\'. Mapping to Bug Jira issue type') - return {"id": JIRA_BUG_TYPE_ID} # JIRA API needs JSON here + return {'id': JIRA_BUG_TYPE_ID} # JIRA API needs JSON here for issue_type in issue_types: type_name = issue_type.name.lower() - if gh_label.lower() in [type_name, "type: %s" % (type_name,)]: + if gh_label.lower() in [type_name, f'type: {type_name}']: # a match! - print("Mapping GitHub label '%s' to JIRA issue type '%s'" % (gh_label, issue_type.name)) - return {"id": issue_type.id} # JIRA API needs JSON here + print(f"Mapping GitHub label '{gh_label}' to JIRA issue type '{issue_type.name}'") + return {'id': issue_type.id} # JIRA API needs JSON here return None # updating a field to None seems to cause 'no change' for JIRA @@ -444,23 +443,24 @@ def _find_jira_issue(jira, gh_issue, make_new=False, retries=5): flurry (for example if someone creates and then edits or labels an issue), # and they're not always processed in order. """ - url = gh_issue["html_url"] - jql_query = 'issue in issuesWithRemoteLinksByGlobalId("%s") order by updated desc' % url - print("JQL query: %s" % jql_query) - r = jira.search_issues(jql_query) - if len(r) == 0: - print("WARNING: No JIRA issues have a remote link with globalID '%s'" % url) - - # Check if the github title ends in (JIRA-KEY). If we can find that JIRA issue and the JIRA issue description contains the + url = gh_issue['html_url'] + jql_query = f'issue in issuesWithRemoteLinksByGlobalId("{url}") order by updated desc' + print(f'JQL query: {jql_query}') + res = jira.search_issues(jql_query) + if not res: + print(f"WARNING: No JIRA issues have a remote link with globalID '{url}'") + + # Check if the github title ends in (JIRA-KEY). If we can find that JIRA issue and + # the JIRA issue description contains the # GitHub URL, assume this item was manually synced over. - m = re.search(r"\(([A-Z]+-\d+)\)\s*$", gh_issue["title"]) - if m is not None: + match = re.search(r'\(([A-Z]+-\d+)\)\s*$', gh_issue['title']) + if match is not None: try: - issue = jira.issue(m.group(1)) - if gh_issue["html_url"] in issue.fields.description: + issue = jira.issue(match.group(1)) + if gh_issue['html_url'] in issue.fields.description: print( - "Looks like this JIRA issue %s was manually synced. Adding a remote link for future lookups." - % issue.key + f'Looks like this JIRA issue {issue.key} was manually synced.' + 'Adding a remote link for future lookups.' ) _add_remote_link(jira, issue, gh_issue) return issue @@ -472,7 +472,8 @@ def _find_jira_issue(jira, gh_issue, make_new=False, retries=5): if not make_new: return None - elif retries > 0: + + if retries > 0: # Wait a random amount of time to see if this JIRA issue is still being created by another # GitHub Action. This is a hacky way to try and avoid the case where a GitHub issue is created # and edited in a short window of time, and the two GitHub Actions race each other and produce @@ -482,41 +483,44 @@ def _find_jira_issue(jira, gh_issue, make_new=False, retries=5): # delayed by more than (retries * min(range)) seconds. This does mean that it can take up to 5 # minutes for an old issue (created before the sync was installed), or an issue where the created # event sync failed, to sync in. - print('Waiting to see if issue is created by another Action... (retries={})'.format(retries)) + print(f'Waiting to see if issue is created by another Action... (retries={retries})') time.sleep(random.randrange(30, 60)) return _find_jira_issue(jira, gh_issue, True, retries - 1) - else: - print('Creating missing issue in JIRA') - return _create_jira_issue(jira, gh_issue) - if len(r) > 1: - print("WARNING: Remote Link globalID '%s' returns multiple JIRA issues. Using last-updated only." % url) - return r[0] + + print('Creating missing issue in JIRA') + return _create_jira_issue(jira, gh_issue) + + if len(res) > 1: + print(f"WARNING: Remote Link globalID '{url}' returns multiple JIRA issues. Using last-updated only.") + return res[0] def _leave_jira_issue_comment(jira, event, verb, should_create, jira_issue=None): """ Leave a simple comment that the GitHub issue corresponding to this event was 'verb' by the GitHub user in question. - If jira_issue is set then this JIRA issue will be updated, otherwise the function will find the corresponding synced issue. + If jira_issue is set then this JIRA issue will be updated, + otherwise the function will find the corresponding synced issue. If should_create is set then a new JIRA issue will be opened if one can't be found. """ - gh_issue = event["issue"] - is_pr = "pull_request" in gh_issue + gh_issue = event['issue'] + is_pr = 'pull_request' in gh_issue if jira_issue is None: - jira_issue = _find_jira_issue(jira, event["issue"], should_create) + jira_issue = _find_jira_issue(jira, event['issue'], should_create) if jira_issue is None: return None try: - user = event["sender"]["login"] + user = event['sender']['login'] except KeyError: - user = gh_issue["user"]["login"] + user = gh_issue['user']['login'] jira.add_comment( jira_issue.id, - "The [GitHub %s|%s] has been %s by @%s" % ("PR" if is_pr else "issue", gh_issue["html_url"], verb, user), + f"The [GitHub {'PR' if is_pr else 'issue'}|{gh_issue['html_url']}] has been {verb} by @{user}", ) + return jira_issue @@ -526,10 +530,10 @@ def _get_jira_comment_body(gh_comment, body=None): or on an existing comment body message (if set). """ if body is None: - body = _markdown2wiki(gh_comment["body"]) - return "[GitHub issue comment|%s] by @%s:\n\n%s" % (gh_comment["html_url"], gh_comment["user"]["login"], body) + body = _markdown2wiki(gh_comment['body']) + return f"[GitHub issue comment|{gh_comment['html_url']}] by @{gh_comment['user']['login']}:\n\n{body}" def _get_jira_label(gh_label): """Reformat a github API label item as something suitable for JIRA""" - return gh_label["name"].replace(" ", "-") + return gh_label['name'].replace(' ', '-') diff --git a/sync_pr.py b/sync_pr.py index c137e49..b2ba258 100755 --- a/sync_pr.py +++ b/sync_pr.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2019 Espressif Systems (Shanghai) PTE LTD +# Copyright 2019-2024 Espressif Systems (Shanghai) PTE LTD # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,9 +15,11 @@ # limitations under the License. # import os -from jira import JIRA + from github import Github -from sync_issue import _find_jira_issue, _create_jira_issue + +from sync_issue import _create_jira_issue +from sync_issue import _find_jira_issue def sync_remain_prs(jira): @@ -26,18 +28,20 @@ def sync_remain_prs(jira): """ github = Github(os.environ['GITHUB_TOKEN']) repo = github.get_repo(os.environ['GITHUB_REPOSITORY']) - prs = repo.get_pulls(state="open", sort="created", direction="desc") + prs = repo.get_pulls(state='open', sort='created', direction='desc') for pr in prs: if not repo.has_in_collaborators(pr.user.login): # mock a github issue using current PR - gh_issue = {"pull_request": True, - "labels": [{"name": l.name} for l in pr.labels], - "number": pr.number, - "title": pr.title, - "html_url": pr.html_url, - "user": {"login": pr.user.login}, - "state": pr.state, - "body": pr.body} + gh_issue = { + 'pull_request': True, + 'labels': [{'name': lbl.name} for lbl in pr.labels], + 'number': pr.number, + 'title': pr.title, + 'html_url': pr.html_url, + 'user': {'login': pr.user.login}, + 'state': pr.state, + 'body': pr.body, + } issue = _find_jira_issue(jira, gh_issue) if issue is None: _create_jira_issue(jira, gh_issue) diff --git a/sync_to_jira.py b/sync_to_jira.py index 924db37..2d6b508 100755 --- a/sync_to_jira.py +++ b/sync_to_jira.py @@ -14,13 +14,24 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from jira import JIRA -from github import Github -import os -import sys import json +import os + +from github import Github +from jira import JIRA + +from sync_issue import handle_comment_created +from sync_issue import handle_comment_deleted +from sync_issue import handle_comment_edited +from sync_issue import handle_issue_closed +from sync_issue import handle_issue_deleted +from sync_issue import handle_issue_edited +from sync_issue import handle_issue_labeled +from sync_issue import handle_issue_opened +from sync_issue import handle_issue_reopened +from sync_issue import handle_issue_unlabeled +from sync_issue import sync_issues_manually from sync_pr import sync_remain_prs -from sync_issue import * class _JIRA(JIRA): @@ -43,11 +54,11 @@ def main(): # Check if the JIRA_PASS is token or password token_or_pass = os.environ['JIRA_PASS'] if token_or_pass.startswith('token:'): - print("Authenticating with JIRA_TOKEN ...") + print('Authenticating with JIRA_TOKEN ...') token = token_or_pass[6:] # Strip the 'token:' prefix jira = _JIRA(os.environ['JIRA_URL'], token_auth=token) else: - print("Authenticating with JIRA_USER and JIRA_PASS ...") + print('Authenticating with JIRA_USER and JIRA_PASS ...') jira = _JIRA(os.environ['JIRA_URL'], basic_auth=(os.environ['JIRA_USER'], token_or_pass)) # Check if it's a cron job @@ -56,13 +67,14 @@ def main(): return # The path of the file with the complete webhook event payload. For example, /github/workflow/event.json. - with open(os.environ['GITHUB_EVENT_PATH'], 'r') as f: - event = json.load(f) + with open(os.environ['GITHUB_EVENT_PATH'], 'r', encoding='utf-8') as file: + event = json.load(file) print(json.dumps(event, indent=4)) event_name = os.environ['GITHUB_EVENT_NAME'] - # Check if event is workflow_dispatch and action is mirror issues. If so, run manual mirroring and skip rest of the script. Works both for issues and pull requests. + # Check if event is workflow_dispatch and action is mirror issues. + # If so, run manual mirroring and skip rest of the script. Works both for issues and pull requests. if event_name == 'workflow_dispatch': inputs = event.get('inputs') @@ -84,23 +96,23 @@ def main(): return # The name of the webhook event that triggered the workflow. - action = event["action"] + action = event['action'] if event_name == 'pull_request': # Treat pull request events just like issues events for syncing purposes # (we can check the 'pull_request' key in the "issue" later to know if this is an issue or a PR) event_name = 'issues' - event["issue"] = event["pull_request"] - if "pull_request" not in event["issue"]: - event["issue"]["pull_request"] = True # we don't care about the value + event['issue'] = event['pull_request'] + if 'pull_request' not in event['issue']: + event['issue']['pull_request'] = True # we don't care about the value # don't sync if user is our collaborator github = Github(os.environ['GITHUB_TOKEN']) repo = github.get_repo(os.environ['GITHUB_REPOSITORY']) - gh_issue = event["issue"] - is_pr = "pull_request" in gh_issue - if is_pr and repo.has_in_collaborators(gh_issue["user"]["login"]): - print("Skipping issue sync for Pull Request from collaborator") + gh_issue = event['issue'] + is_pr = 'pull_request' in gh_issue + if is_pr and repo.has_in_collaborators(gh_issue['user']['login']): + print('Skipping issue sync for Pull Request from collaborator') return action_handlers = { @@ -121,12 +133,12 @@ def main(): } if event_name not in action_handlers: - print("No handler for event '%s'. Skipping." % event_name) + print(f"No handler for event '{event_name}'. Skipping.") elif action not in action_handlers[event_name]: - print("No handler '%s' action '%s'. Skipping." % (event_name, action)) + print(f"No handler '{event_name}' action '{action}'. Skipping.") else: action_handlers[event_name][action](jira, event) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/test_sync_to_jira.py b/test_sync_to_jira.py index ccd8cc3..b573f58 100755 --- a/test_sync_to_jira.py +++ b/test_sync_to_jira.py @@ -14,18 +14,19 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import jira -import github import json -import sync_to_jira -import sync_issue import os -import unittest +import tempfile import unittest.mock from unittest.mock import create_autospec -import tempfile -MOCK_GITHUB_TOKEN = "iamagithubtoken" +import github +import jira + +import sync_issue +import sync_to_jira + +MOCK_GITHUB_TOKEN = 'iamagithubtoken' def run_sync_issue(event_name, event, jira_issue=None): @@ -37,7 +38,7 @@ def run_sync_issue(event_name, event, jira_issue=None): """ try: # dump the event data to a JSON file - event_file = tempfile.NamedTemporaryFile('w+', delete=False) + event_file = tempfile.NamedTemporaryFile('w+', delete=False) # pylint: disable=consider-using-with json.dump(event, event_file) event_file.close() @@ -53,7 +54,7 @@ def run_sync_issue(event_name, event, jira_issue=None): github_class = create_autospec(github.Github) - gh_repo_class = create_autospec(github.Repository.Repository) + create_autospec(github.Repository.Repository) # tell repo.has_in_collaborators() to return False by default github_class.return_value.get_repo.return_value.has_in_collaborators.return_value = False @@ -62,13 +63,13 @@ def run_sync_issue(event_name, event, jira_issue=None): # fake a issue_types response also issue_type_bug = create_autospec(jira.resources.IssueType) - issue_type_bug.name = "Bug" + issue_type_bug.name = 'Bug' issue_type_bug.id = 5001 issue_type_task = create_autospec(jira.resources.IssueType) - issue_type_task.name = "Task" + issue_type_task.name = 'Task' issue_type_task.id = 5002 issue_type_new_feature = create_autospec(jira.resources.IssueType) - issue_type_task.name = "New Feature" + issue_type_task.name = 'New Feature' issue_type_task.id = 5003 jira_class.return_value.issue_types.return_value = [ @@ -80,17 +81,19 @@ def run_sync_issue(event_name, event, jira_issue=None): if jira_issue is not None: jira_class.return_value.search_issues.return_value = [jira_issue] remote_link = create_autospec(jira.resources.RemoteLink) - remote_link.globalId = event["issue"]["html_url"] - remote_link.relationship = "synced from" - remote_link.raw = {"object": { - "title": event["issue"]["title"], - "status": {}, - }} + remote_link.globalId = event['issue']['html_url'] + remote_link.relationship = 'synced from' + remote_link.raw = { + 'object': { + 'title': event['issue']['title'], + 'status': {}, + } + } jira_class.return_value.remote_links.return_value = [remote_link] else: jira_class.return_value.search_issues.return_value = [] - sync_to_jira._JIRA = jira_class + sync_to_jira._JIRA = jira_class # pylint: disable=protected-access sync_to_jira.Github = github_class sync_issue.Github = github_class sync_to_jira.main() @@ -102,106 +105,105 @@ def run_sync_issue(event_name, event, jira_issue=None): class TestIssuesEvents(unittest.TestCase): - def test_issue_opened(self): - issue = {"html_url": "https://github.com/espressif/fake/issues/3", - "repository_url": "https://github.com/espressif/fake", - "number": 3, - "title": "Test issue", - "body": "I am a new test issue\nabc\n测试\n", - "user": {"login": "testuser"}, - "labels": [{"name": "bug"}], - "state": "open", - } - event = {"action": "opened", - "issue": issue - } + issue = { + 'html_url': 'https://github.com/espressif/fake/issues/3', + 'repository_url': 'https://github.com/espressif/fake', + 'number': 3, + 'title': 'Test issue', + 'body': 'I am a new test issue\nabc\n测试\n', + 'user': {'login': 'testuser'}, + 'labels': [{'name': 'bug'}], + 'state': 'open', + } + event = {'action': 'opened', 'issue': issue} m_jira = run_sync_issue('issues', event) # Check that create_issue() was called with fields param resembling the GH issue fields = m_jira.create_issue.call_args[0][0] - self.assertIn(issue["title"], fields["summary"]) - self.assertIn(issue["body"], fields["description"]) - self.assertIn(issue["html_url"], fields["description"]) + self.assertIn(issue['title'], fields['summary']) + self.assertIn(issue['body'], fields['description']) + self.assertIn(issue['html_url'], fields['description']) # Mentions 'issue', no mention of 'pull request' - self.assertIn("issue", fields["description"]) - self.assertNotIn("pr", fields["summary"].lower()) - self.assertNotIn("pull request", fields["description"].lower()) + self.assertIn('issue', fields['description']) + self.assertNotIn('pr', fields['summary'].lower()) + self.assertNotIn('pull request', fields['description'].lower()) # Check that add_remote_link() was called rl_args = m_jira.add_remote_link.call_args[1] - self.assertEqual(m_jira.create_issue.return_value, rl_args["issue"]) - self.assertEqual(issue["html_url"], rl_args["globalId"]) + self.assertEqual(m_jira.create_issue.return_value, rl_args['issue']) + self.assertEqual(issue['html_url'], rl_args['globalId']) # check that the github repo was updated via expected sequence of API calls sync_issue.Github.assert_called_with(MOCK_GITHUB_TOKEN) github_obj = sync_issue.Github.return_value - github_obj.get_repo.assert_called_with("espressif/fake") + github_obj.get_repo.assert_called_with('espressif/fake') repo_obj = github_obj.get_repo.return_value - repo_obj.get_issue.assert_called_with(issue["number"]) + repo_obj.get_issue.assert_called_with(issue['number']) issue_obj = repo_obj.get_issue.return_value update_args = issue_obj.edit.call_args[1] - self.assertIn("title", update_args) + self.assertIn('title', update_args) def test_issue_closed(self): - m_jira = self._test_issue_simple_comment("closed") + m_jira = self._test_issue_simple_comment('closed') # check resolved was set new_object = m_jira.remote_links.return_value[0].update.call_args[0][0] - new_status = new_object["status"] - self.assertEqual(True, new_status["resolved"]) + new_status = new_object['status'] + self.assertEqual(True, new_status['resolved']) def test_issue_deleted(self): - self._test_issue_simple_comment("deleted") + self._test_issue_simple_comment('deleted') def test_issue_reopened(self): - m_jira = self._test_issue_simple_comment("reopened") + m_jira = self._test_issue_simple_comment('reopened') # check resolved was cleared new_object = m_jira.remote_links.return_value[0].update.call_args[0][0] - new_status = new_object["status"] - self.assertEqual(False, new_status["resolved"]) + new_status = new_object['status'] + self.assertEqual(False, new_status['resolved']) def test_issue_edited(self): - issue = {"html_url": "https://github.com/espressif/fake/issues/11", - "repository_url": "https://github.com/espressif/fake", - "number": 11, - "title": "Edited issue", - "body": "Edited issue content goes here", - "user": {"login": "edituser"}, - "state": "open", - "labels": [], - } - - m_jira = self._test_issue_simple_comment("edited", issue) + issue = { + 'html_url': 'https://github.com/espressif/fake/issues/11', + 'repository_url': 'https://github.com/espressif/fake', + 'number': 11, + 'title': 'Edited issue', + 'body': 'Edited issue content goes here', + 'user': {'login': 'edituser'}, + 'state': 'open', + 'labels': [], + } + + m_jira = self._test_issue_simple_comment('edited', issue) # check the update resembles the edited issue m_issue = m_jira.search_issues.return_value[0] update_args = m_issue.update.call_args[1] - self.assertIn("description", update_args["fields"]) - self.assertIn("summary", update_args["fields"]) - self.assertIn(issue["title"], update_args["fields"]["summary"]) + self.assertIn('description', update_args['fields']) + self.assertIn('summary', update_args['fields']) + self.assertIn(issue['title'], update_args['fields']['summary']) def _test_issue_simple_comment(self, action, gh_issue=None): """ - Wrapper for the simple case of updating an issue (with 'action'). GitHub issue fields can be supplied, or generic ones will be used. + Wrapper for the simple case of updating an issue (with 'action'). + GitHub issue fields can be supplied, or generic ones will be used. """ if gh_issue is None: gh_number = hash(action) % 43 - gh_issue = {"html_url": "https://github.com/espressif/fake/issues/%d" % gh_number, - "number": gh_number, - "title": "Test issue", - "body": "I am a test issue\nabc\n\n", - "user": {"login": "otheruser"}, - "labels": [{"name": "Type: New Feature"}], - "state": "closed" if action in ["closed", "deleted"] else "open", - } - event = {"action": action, - "issue": gh_issue - } + gh_issue = { + 'html_url': f'https://github.com/espressif/fake/issues/{gh_number}', + 'number': gh_number, + 'title': 'Test issue', + 'body': 'I am a test issue\nabc\n\n', + 'user': {'login': 'otheruser'}, + 'labels': [{'name': 'Type: New Feature'}], + 'state': 'closed' if action in ['closed', 'deleted'] else 'open', + } + event = {'action': action, 'issue': gh_issue} m_issue = create_autospec(jira.Issue)(None, None) jira_id = hash(action) % 1001 @@ -212,87 +214,89 @@ def _test_issue_simple_comment(self, action, gh_issue=None): # expect JIRA API added a comment about the action comment_jira_id, comment = m_jira.add_comment.call_args[0] self.assertEqual(jira_id, comment_jira_id) - self.assertIn(gh_issue["user"]["login"], comment) + self.assertIn(gh_issue['user']['login'], comment) self.assertIn(action, comment) return m_jira def test_pr_opened(self): - pr = {"html_url": "https://github.com/espressif/fake/pulls/4", - "base": {"repo": {"html_url": "https://github.com/espressif/fake"}}, - "number": 4, - "title": "Test issue", - "body": "I am a new Pull Request!\nabc\n测试\n", - "user": {"login": "testuser"}, - "labels": [{"name": "bug"}], - "state": "open", - } - event = {"action": "opened", - "pull_request": pr - } + pr = { + 'html_url': 'https://github.com/espressif/fake/pulls/4', + 'base': {'repo': {'html_url': 'https://github.com/espressif/fake'}}, + 'number': 4, + 'title': 'Test issue', + 'body': 'I am a new Pull Request!\nabc\n测试\n', + 'user': {'login': 'testuser'}, + 'labels': [{'name': 'bug'}], + 'state': 'open', + } + event = {'action': 'opened', 'pull_request': pr} m_jira = run_sync_issue('pull_request', event) # Check that create_issue() mentions a PR not an issue fields = m_jira.create_issue.call_args[0][0] - self.assertIn("PR", fields["summary"]) - self.assertIn("Pull Request", fields["description"]) - self.assertIn(pr["html_url"], fields["description"]) + self.assertIn('PR', fields['summary']) + self.assertIn('Pull Request', fields['description']) + self.assertIn(pr['html_url'], fields['description']) class TestIssueCommentEvents(unittest.TestCase): - def test_issue_comment_created(self): - self._test_issue_comment("created") + self._test_issue_comment('created') def test_issue_comment_deleted(self): - self._test_issue_comment("deleted") + self._test_issue_comment('deleted') def test_issue_comment_edited(self): - self._test_issue_comment("edited", extra_event_data={"changes": {"body": {"from": "I am the old comment body"}}}) + self._test_issue_comment( + 'edited', extra_event_data={'changes': {'body': {'from': 'I am the old comment body'}}} + ) - def _test_issue_comment(self, action, gh_issue=None, gh_comment=None, extra_event_data={}): + def _test_issue_comment( + self, action, gh_issue=None, gh_comment=None, extra_event_data={} + ): # pylint: disable=dangerous-default-value """ - Wrapper for the simple case of an issue comment event (with 'action'). GitHub issue and comment fields can be supplied, or generic ones will be used. + Wrapper for the simple case of an issue comment event (with 'action'). + GitHub issue and comment fields can be supplied, or generic ones will be used. """ if gh_issue is None: gh_number = hash(action) % 50 - gh_issue = {"html_url": "https://github.com/espressif/fake/issues/%d" % gh_number, - "repository_url": "https://github.com/espressif/fake", - "number": gh_number, - "title": "Test issue", - "body": "I am a test issue\nabc\n\n", - "user": {"login": "otheruser"}, - "labels": [] - } + gh_issue = { + 'html_url': f'https://github.com/espressif/fake/issues/{gh_number}', + 'repository_url': 'https://github.com/espressif/fake', + 'number': gh_number, + 'title': 'Test issue', + 'body': 'I am a test issue\nabc\n\n', + 'user': {'login': 'otheruser'}, + 'labels': [], + } if gh_comment is None: gh_comment_id = hash(action) % 404 - gh_comment = {"html_url": gh_issue["html_url"] + "#" + str(gh_comment_id), - "repository_url": "https://github.com/espressif/fake", - "id": gh_comment_id, - "user": {"login": "commentuser"}, - "body": "ZOMG a comment!" - } - event = {"action": action, - "issue": gh_issue, - "comment": gh_comment - } + gh_comment = { + 'html_url': gh_issue['html_url'] + '#' + str(gh_comment_id), + 'repository_url': 'https://github.com/espressif/fake', + 'id': gh_comment_id, + 'user': {'login': 'commentuser'}, + 'body': 'ZOMG a comment!', + } + event = {'action': action, 'issue': gh_issue, 'comment': gh_comment} event.update(extra_event_data) m_issue = create_autospec(jira.Issue)(None, None) jira_id = hash(action) % 1003 m_issue.id = jira_id - m_issue.key = "FAKEFAKE-%d" % (hash(action) % 333,) + m_issue.key = f'FAKEFAKE-{hash(action) % 333}' m_jira = run_sync_issue('issue_comment', event, m_issue) # expect JIRA API added a comment about the action comment_jira_id, comment = m_jira.add_comment.call_args[0] self.assertEqual(jira_id, comment_jira_id) - self.assertIn(gh_comment["user"]["login"], comment) - self.assertIn(gh_comment["html_url"], comment) - if action != "deleted": - self.assertIn(gh_comment["body"], comment) # note: doesn't account for markdown2wiki + self.assertIn(gh_comment['user']['login'], comment) + self.assertIn(gh_comment['html_url'], comment) + if action != 'deleted': + self.assertIn(gh_comment['body'], comment) # note: doesn't account for markdown2wiki return m_jira