├── tests ├── __init__.py ├── configs │ ├── fragment_config_failing.py │ ├── incomplete_config.py │ ├── minimal_config.py │ ├── fragment_config_overrides.py │ ├── bad_config.py │ ├── fragment_config_base.py │ └── full_config.py ├── constants.py ├── test_entities.py ├── test_utils.py ├── test_command_line.py ├── test_permissions.py ├── test_config.py ├── conftest.py ├── mocks.py └── test_github.py ├── jirahub ├── resources │ ├── __init__.py │ └── config_template.py ├── __init__.py ├── entities.py ├── utils.py ├── permissions.py ├── config.py ├── command_line.py ├── github.py ├── jira.py └── jirahub.py ├── pyproject.toml ├── setup.py ├── MANIFEST.in ├── .readthedocs.yml ├── docs ├── source │ ├── _static │ │ └── theme_overrides.css │ ├── conf.py │ └── index.rst └── Makefile ├── README.rst ├── CHANGES.rst ├── tox.ini ├── CONTRIBUTING.md ├── setup.cfg ├── LICENSE.rst ├── .github └── workflows │ └── ci.yml ├── .gitignore └── CODE_OF_CONDUCT.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jirahub/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | exclude = ''' 4 | config_template.py 5 | ''' -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup 4 | 5 | setup(use_scm_version=True) 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft jirahub 2 | global-exclude *.py[cod] __pycache__ *.so 3 | include README.rst 4 | include LICENSE.rst 5 | -------------------------------------------------------------------------------- /jirahub/__init__.py: -------------------------------------------------------------------------------- 1 | from .jirahub import IssueSync 2 | from .config import load_config 3 | 4 | __all__ = ["IssueSync", "load_config"] 5 | -------------------------------------------------------------------------------- /tests/configs/fragment_config_failing.py: -------------------------------------------------------------------------------- 1 | def failing_body_formatter(issue, body): 2 | raise Exception("Nope") 3 | 4 | 5 | c.jira.issue_body_formatter = failing_body_formatter 6 | c.github.issue_body_formatter = failing_body_formatter 7 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | formats: all 4 | 5 | python: 6 | version: 3.8 7 | install: 8 | - method: pip 9 | path: . 10 | extra_requirements: 11 | - docs 12 | 13 | sphinx: 14 | configuration: docs/source/conf.py 15 | -------------------------------------------------------------------------------- /tests/configs/incomplete_config.py: -------------------------------------------------------------------------------- 1 | # This config is missing the required key 'project_key' 2 | 3 | c.jira.server = "https://test.jira.server" 4 | c.jira.github_issue_url_field_id = 12345 5 | c.jira.jirahub_metadata_field_id = 67890 6 | 7 | c.github.repository = "testing/test-repo" 8 | -------------------------------------------------------------------------------- /tests/configs/minimal_config.py: -------------------------------------------------------------------------------- 1 | # This config contains only the required keys 2 | 3 | c.jira.server = "https://test.jira.server" 4 | c.jira.project_key = "TEST" 5 | c.jira.github_issue_url_field_id = 12345 6 | c.jira.jirahub_metadata_field_id = 67890 7 | 8 | c.github.repository = "testing/test-repo" 9 | -------------------------------------------------------------------------------- /tests/configs/fragment_config_overrides.py: -------------------------------------------------------------------------------- 1 | # This config is incomplete, but will specify all the required key 2 | # when combined with fragment_config_base.py. 3 | 4 | c.jira.project_key = "TEST" 5 | c.jira.max_retries = 7 6 | c.jira.sync_milestones = False 7 | 8 | c.github.repository = "testing/test-repo" 9 | -------------------------------------------------------------------------------- /tests/configs/bad_config.py: -------------------------------------------------------------------------------- 1 | # This config contains resources that the mock clients don't recognize. 2 | 3 | c.jira.server = "https://test.missing.jira.server" 4 | c.jira.project_key = "TEST" 5 | c.jira.github_issue_url_field_id = 12345 6 | c.jira.jirahub_metadata_field_id = 67890 7 | 8 | c.github.repository = "testing/test-missing-repo" 9 | -------------------------------------------------------------------------------- /tests/constants.py: -------------------------------------------------------------------------------- 1 | TEST_JIRA_SERVER = "https://test.jira.server" 2 | TEST_JIRA_PROJECT_KEY = "TEST" 3 | TEST_JIRA_USERNAME = "test-jira-username" 4 | TEST_JIRA_PASSWORD = "test-jira-password" 5 | TEST_JIRA_USER_DISPLAY_NAME = "Test User" 6 | TEST_JIRA_DEFAULT_STATUS = "Open" 7 | TEST_JIRA_DEFAULT_PRIORITY = "Major" 8 | TEST_JIRA_DEFAULT_ISSUE_TYPE = "Bug" 9 | 10 | TEST_GITHUB_REPOSITORY = "testing/test-repo" 11 | TEST_GITHUB_REPOSITORY_NAME = "test-repo" 12 | TEST_GITHUB_TOKEN = "test-github-token" 13 | TEST_GITHUB_USER_LOGIN = "testuser123" 14 | TEST_GITHUB_USER_NAME = "Test User 123" 15 | -------------------------------------------------------------------------------- /docs/source/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* Fix for table text wrapping, courtesy of Rackspace: 2 | https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html */ 3 | 4 | /* override table width restrictions */ 5 | @media screen and (min-width: 767px) { 6 | 7 | .wy-table-responsive table td { 8 | /* !important prevents the common CSS stylesheets from overriding 9 | this as on RTD they are loaded after this stylesheet */ 10 | white-space: normal !important; 11 | } 12 | 13 | .wy-table-responsive { 14 | overflow: visible !important; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pkg_resources import get_distribution 4 | import stsci_rtd_theme 5 | 6 | 7 | def setup(app): 8 | app.add_stylesheet("stsci.css") 9 | 10 | 11 | project = "jirahub" 12 | copyright = f"{datetime.now().year}, STScI" 13 | author = "STScI" 14 | release = get_distribution("jirahub").version 15 | html_theme = "stsci_rtd_theme" 16 | html_theme_path = [stsci_rtd_theme.get_html_theme_path()] 17 | html_static_path = ["_static"] 18 | html_context = {"css_files": ["_static/theme_overrides.css"]} # override wide tables in RTD theme 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Jirahub: a package for syncing JIRA tickets and GitHub issues 2 | ------------------------------------------------------------- 3 | 4 | This package provides a configurable tool for syncing tickets in a JIRA project and issues in a GitHub repository. 5 | 6 | Please see `the documentation `_ for more information 7 | 8 | License 9 | ------- 10 | 11 | This project is Copyright (c) Association of Universities for Research in Astronomy (AURA) and licensed under the terms of the BSD 3-Clause license. See `LICENSE.rst `_ for more information. 12 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 0.3.0 (2021-02-24) 2 | ------------------ 3 | 4 | - Allow hyphens in GitHub usernames. [#27] 5 | 6 | - Add hook to support modifying fields before update. [#29] 7 | 8 | 0.2.2 (2020-08-10) 9 | ------------------ 10 | 11 | - Escape strings from Jira that would be interpreted by GitHub 12 | as user mentions. [#26] 13 | 14 | 0.2.1 (2020-06-25) 15 | ------------------ 16 | 17 | - Documentation fixes 18 | 19 | 0.2.0 (2020-06-25) 20 | ------------------ 21 | 22 | - Introduce configuration file 23 | - Major breaking changes to IssueSync class 24 | - Remove GithubQuery and JiraQuery classes 25 | 26 | 0.1.0 (2019-07-19) 27 | ------------------ 28 | 29 | - Initial release 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, black, flake8, docs, coverage 3 | 4 | [testenv] 5 | extras = dev 6 | whitelist_externals = pytest 7 | commands = 8 | pytest 9 | 10 | [testenv:black] 11 | extras = dev 12 | whitelist_externals = black 13 | commands= 14 | black --check jirahub tests docs/source/conf.py 15 | 16 | [testenv:flake8] 17 | extras = dev 18 | whitelist_externals = flake8 19 | commands = 20 | flake8 --count jirahub tests docs/source/conf.py 21 | 22 | [testenv:build-docs] 23 | extras = docs 24 | commands = 25 | sphinx-build -W docs/source build/docs 26 | 27 | [testenv:coverage] 28 | extras = dev 29 | whitelist_externals = pytest 30 | commands = 31 | pytest --cov=jirahub --cov-fail-under 99 32 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please open a new issue or new pull request for bugs, feedback, or new features 2 | you would like to see. If there is an issue you would like to work on, please 3 | leave a comment and we will be happy to assist. New contributions and 4 | contributors are very welcome! 5 | 6 | The main development work is done on the "master" branch. The rest of the branches 7 | are for release maintenance and should not be used normally. Unless otherwise told by a 8 | maintainer, pull request should be made and submitted to the "master" branch. 9 | 10 | New to GitHub or open source projects? If you are unsure about where to start or 11 | haven't used GitHub before, please feel free to contact the package maintainers. 12 | 13 | Feedback and feature requests? Is there something missing you would like to see? 14 | Please open an issue or send an email to the maintainers. This package follows 15 | the Spacetelescope [Code of Conduct](CODE_OF_CONDUCT.md) and strives to provide 16 | a welcoming community to all of our users and contributors. 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = jirahub 3 | provides = jirahub 4 | package_name = jirahub 5 | author = STScI 6 | author_email = help@stsci.edu 7 | license = BSD 3-Clause 8 | license_file = LICENSE.rst 9 | description = A package for syncing JIRA tickets and GitHub issues 10 | long_description = file: README.rst 11 | long_description_content_type = text/x-rst 12 | url = https://github.com/spacetelescope/jirahub 13 | github_project = spacetelescope/jirahub 14 | 15 | [options] 16 | packages = find: 17 | include_package_data = True 18 | zip_safe = False 19 | python_requires = >=3.7 20 | setup_requires = 21 | setuptools >=41.0.1 22 | setuptools_scm >=3.3.3, <4 23 | install_requires = 24 | jira >=2, <3 25 | pygithub >=1.43.7, <2 26 | 27 | [options.extras_require] 28 | dev = 29 | black >=19.3b0, <20 30 | flake8 31 | pytest >=5.0.1, <6 32 | pytest-cov >= 2.7.1, <3 33 | tox >=3.13.2, <4 34 | docs = 35 | sphinx >2, <3 36 | sphinx_rtd_theme 37 | stsci_rtd_theme 38 | 39 | [options.entry_points] 40 | console_scripts = 41 | jirahub = jirahub.command_line:main 42 | 43 | [flake8] 44 | ignore = E501, E203, W503, E741 45 | exclude = .git, __pycache__, build, dist, eggs, *.egg, config_template.py, tests/configs 46 | 47 | [tool:pytest] 48 | testpaths = tests 49 | 50 | [coverage:run] 51 | omit = 52 | jirahub/resources/config_template.py 53 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | ==================== 3 | 4 | Copyright (c) Space Telescope Science Institute, AURA All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | Redistributions in binary form must reproduce the above copyright notice, this 13 | list of conditions and the following disclaimer in the documentation and/or 14 | other materials provided with the distribution. 15 | 16 | Neither the name of the copyright holder nor the names of its contributors may 17 | be used to endorse or promote products derived from this software without 18 | specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | pull_request: 10 | 11 | jobs: 12 | tox: 13 | name: ${{ matrix.name }} 14 | runs-on: ${{ matrix.runs-on }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | - name: Python 3.9 20 | runs-on: ubuntu-latest 21 | python-version: 3.9 22 | toxenv: py39 23 | 24 | - name: Python 3.8 25 | runs-on: ubuntu-latest 26 | python-version: 3.8 27 | toxenv: py38 28 | 29 | - name: Python 3.7 30 | runs-on: ubuntu-latest 31 | python-version: 3.7 32 | toxenv: py37 33 | 34 | - name: Coverage 35 | runs-on: ubuntu-latest 36 | python-version: 3.8 37 | toxenv: coverage 38 | 39 | - name: Black 40 | runs-on: ubuntu-latest 41 | python-version: 3.8 42 | toxenv: black 43 | 44 | - name: Flake8 45 | runs-on: ubuntu-latest 46 | python-version: 3.8 47 | toxenv: flake8 48 | 49 | - name: Build documentation and check warnings 50 | runs-on: ubuntu-latest 51 | python-version: 3.8 52 | toxenv: build-docs 53 | steps: 54 | - uses: actions/checkout@v2 55 | with: 56 | fetch-depth: 0 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v2 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | - name: Install tox 62 | run: | 63 | python -m pip install --upgrade pip 64 | pip install tox 65 | - name: Run tox 66 | run: tox -e ${{ matrix.toxenv }} 67 | -------------------------------------------------------------------------------- /tests/test_entities.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import dataclasses 3 | 4 | from jirahub.entities import Source 5 | 6 | 7 | class TestSource: 8 | def test_str(self): 9 | assert str(Source.JIRA) == "JIRA" 10 | assert str(Source.GITHUB) == "GitHub" 11 | 12 | def test_other(self): 13 | assert Source.JIRA.other == Source.GITHUB 14 | assert Source.GITHUB.other == Source.JIRA 15 | 16 | 17 | class TestUser: 18 | def test_frozen(self, create_user): 19 | user = create_user(Source.JIRA) 20 | with pytest.raises(dataclasses.FrozenInstanceError): 21 | user.username = "nope" 22 | 23 | @pytest.mark.parametrize("source", list(Source)) 24 | def test_str(self, create_user, source): 25 | user = create_user(source) 26 | user_str = str(user) 27 | assert str(source) in user_str 28 | assert user.username in user_str 29 | assert user.display_name in user_str 30 | 31 | 32 | class TestComment: 33 | def test_frozen(self, create_comment): 34 | comment = create_comment(Source.JIRA) 35 | with pytest.raises(dataclasses.FrozenInstanceError): 36 | comment.body = "nope" 37 | 38 | @pytest.mark.parametrize("source", list(Source)) 39 | def test_str(self, create_comment, source): 40 | comment = create_comment(source) 41 | comment_str = str(comment) 42 | 43 | assert str(source) in comment_str 44 | assert str(comment.comment_id) in comment_str 45 | 46 | 47 | class TestIssue: 48 | def test_frozen(self, create_issue): 49 | issue = create_issue(Source.JIRA) 50 | with pytest.raises(dataclasses.FrozenInstanceError): 51 | issue.body = "nope" 52 | 53 | @pytest.mark.parametrize("source", list(Source)) 54 | def test_str(self, create_issue, source): 55 | issue = create_issue(source) 56 | issue_str = str(issue) 57 | 58 | assert str(source) in issue_str 59 | assert str(issue.issue_id) in issue_str 60 | -------------------------------------------------------------------------------- /tests/configs/fragment_config_base.py: -------------------------------------------------------------------------------- 1 | # This config is incomplete, but will specify all the required keys 2 | # when combined with fragment_config_overrides.py. 3 | 4 | import re 5 | 6 | c.jira.server = "https://test.jira.server" 7 | c.jira.github_issue_url_field_id = 12345 8 | c.jira.jirahub_metadata_field_id = 67890 9 | c.jira.closed_statuses = ["Closed", "Done"] 10 | c.jira.close_status = "Done" 11 | c.jira.reopen_status = "Ready" 12 | c.jira.open_status = "Open" 13 | c.jira.issue_filter = lambda issue: True 14 | c.jira.notify_watchers = False 15 | c.jira.sync_comments = True 16 | c.jira.sync_status = True 17 | c.jira.sync_labels = True 18 | c.jira.sync_milestones = True 19 | c.jira.issue_title_formatter = lambda issue, title: "From GitHub: " + title 20 | c.jira.issue_body_formatter = lambda issue, body: "Check out this great GitHub issue:\n\n" + body 21 | c.jira.comment_body_formatter = lambda issue, comment, body: "Check out this great GitHub comment:\n\n" + body 22 | c.jira.redact_patterns.append(re.compile(r"(?<=secret GitHub data: ).+?\b")) 23 | 24 | 25 | def jira_issue_create_hook(_, fields): 26 | fields["issue_type"] = "Bug" 27 | return fields 28 | 29 | 30 | c.jira.before_issue_create.append(jira_issue_create_hook) 31 | 32 | c.github.max_retries = 10 33 | c.github.issue_filter = lambda issue: True 34 | c.github.sync_comments = True 35 | c.github.sync_status = True 36 | c.github.sync_labels = True 37 | c.github.sync_milestones = True 38 | c.github.issue_title_formatter = lambda issue, title: "From JIRA: " + title 39 | c.github.issue_body_formatter = lambda issue, body: "Check out this great JIRA issue:\n\n" + body 40 | c.github.comment_body_formatter = lambda issue, comment, body: "Check out this great JIRA comment:\n\n" + body 41 | c.github.redact_patterns.append(re.compile(r"(?<=secret JIRA data: ).+?\b")) 42 | 43 | 44 | def github_issue_create_hook(issue, fields): 45 | fields["labels"] = ["jirahub"] 46 | return fields 47 | 48 | 49 | c.github.before_issue_create.append(github_issue_create_hook) 50 | -------------------------------------------------------------------------------- /tests/configs/full_config.py: -------------------------------------------------------------------------------- 1 | # This config contains custom settings for all keys 2 | 3 | import re 4 | 5 | 6 | def before_update_hook(updated_issue, updated_issue_fields, other_issue, other_issue_fields): 7 | pass 8 | 9 | 10 | c.before_issue_update.append(before_update_hook) 11 | 12 | c.jira.server = "https://test.jira.server" 13 | c.jira.project_key = "TEST" 14 | c.jira.github_issue_url_field_id = 12345 15 | c.jira.jirahub_metadata_field_id = 67890 16 | c.jira.closed_statuses = ["Closed", "Done"] 17 | c.jira.close_status = "Done" 18 | c.jira.reopen_status = "Ready" 19 | c.jira.open_status = "Open" 20 | c.jira.max_retries = 5 21 | c.jira.notify_watchers = False 22 | c.jira.issue_filter = lambda issue: True 23 | c.jira.sync_comments = True 24 | c.jira.sync_status = True 25 | c.jira.sync_labels = True 26 | c.jira.sync_milestones = True 27 | c.jira.create_tracking_comment = True 28 | c.jira.issue_title_formatter = lambda issue, title: "From GitHub: " + title 29 | c.jira.issue_body_formatter = lambda issue, body: "Check out this great GitHub issue:\n\n" + body 30 | c.jira.comment_body_formatter = lambda issue, comment, body: "Check out this great GitHub comment:\n\n" + body 31 | c.jira.redact_patterns.append(re.compile(r"(?<=secret GitHub data: ).+?\b")) 32 | 33 | 34 | def jira_issue_create_hook(issue, fields): 35 | fields["issue_type"] = "Bug" 36 | return fields 37 | 38 | 39 | c.jira.before_issue_create.append(jira_issue_create_hook) 40 | 41 | c.github.repository = "testing/test-repo" 42 | c.github.max_retries = 10 43 | c.github.issue_filter = lambda _: True 44 | c.github.sync_comments = True 45 | c.github.sync_status = True 46 | c.github.sync_labels = True 47 | c.github.sync_milestones = True 48 | c.github.create_tracking_comment = True 49 | c.github.issue_title_formatter = lambda issue, title: "From JIRA: " + title 50 | c.github.issue_body_formatter = lambda issue, body: "Check out this great JIRA issue:\n\n" + body 51 | c.github.comment_body_formatter = lambda issue, comment, body: "Check out this great JIRA comment:\n\n" + body 52 | c.github.redact_patterns.append(re.compile(r"(?<=secret JIRA data: ).+?\b")) 53 | 54 | 55 | def github_issue_create_hook(_, fields): 56 | fields["labels"] = ["jirahub"] 57 | return fields 58 | 59 | 60 | c.github.before_issue_create.append(github_issue_create_hook) 61 | -------------------------------------------------------------------------------- /jirahub/entities.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass, field 3 | from datetime import datetime 4 | from typing import List, Any, Set 5 | 6 | 7 | __all__ = ["Source", "User", "Comment", "Issue", "Metadata", "CommentMetadata"] 8 | 9 | 10 | class Source(Enum): 11 | JIRA = "JIRA" 12 | GITHUB = "GitHub" 13 | 14 | def __str__(self): 15 | return self.value 16 | 17 | @property 18 | def other(self): 19 | if self == Source.JIRA: 20 | return Source.GITHUB 21 | else: 22 | return Source.JIRA 23 | 24 | 25 | @dataclass(frozen=True) 26 | class CommentMetadata: 27 | jira_comment_id: int 28 | github_comment_id: int 29 | 30 | 31 | @dataclass(frozen=True) 32 | class Metadata: 33 | github_repository: str = None 34 | github_issue_id: int = None 35 | github_tracking_comment_id: int = None 36 | jira_tracking_comment_id: int = None 37 | comments: List[CommentMetadata] = field(default_factory=list) 38 | 39 | 40 | @dataclass(frozen=True) 41 | class User: 42 | source: Source 43 | username: str 44 | display_name: str 45 | raw_user: Any = None 46 | 47 | def __str__(self): 48 | return f"{self.source} user {self.display_name} ({self.username})" 49 | 50 | 51 | @dataclass(frozen=True) 52 | class Comment: 53 | source: Source 54 | comment_id: int 55 | created_at: datetime 56 | updated_at: datetime 57 | user: User 58 | is_bot: bool 59 | body: str = "" 60 | raw_comment: Any = None 61 | 62 | def __str__(self): 63 | return f"{self.source} comment {self.comment_id}" 64 | 65 | 66 | @dataclass(frozen=True) 67 | class Issue: 68 | source: Source 69 | is_bot: bool 70 | issue_id: Any 71 | project: str 72 | created_at: datetime 73 | updated_at: datetime 74 | user: User 75 | title: str 76 | is_open: bool 77 | body: str = "" 78 | labels: Set[str] = field(default_factory=set) 79 | priority: str = None 80 | issue_type: str = None 81 | milestones: Set[str] = field(default_factory=set) 82 | components: Set[str] = field(default_factory=set) 83 | comments: List[Comment] = field(default_factory=list) 84 | metadata: Metadata = field(default_factory=Metadata) 85 | raw_issue: Any = None 86 | 87 | def __str__(self): 88 | return f"{self.source} issue {self.issue_id}" 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Spacetelescope Open Source Code of Conduct 2 | 3 | We expect all "spacetelescope" organization projects to adopt a code of conduct that ensures a productive, respectful environment for all open source contributors and participants. We are committed to providing a strong and enforced code of conduct and expect everyone in our community to follow these guidelines when interacting with others in all forums. Our goal is to keep ours a positive, inclusive, successful, and growing community. The community of participants in open source Astronomy projects is made up of members from around the globe with a diverse set of skills, personalities, and experiences. It is through these differences that our community experiences success and continued growth. 4 | 5 | 6 | As members of the community, 7 | 8 | - We pledge to treat all people with respect and provide a harassment- and bullying-free environment, regardless of sex, sexual orientation and/or gender identity, disability, physical appearance, body size, race, nationality, ethnicity, and religion. In particular, sexual language and imagery, sexist, racist, or otherwise exclusionary jokes are not appropriate. 9 | 10 | - We pledge to respect the work of others by recognizing acknowledgment/citation requests of original authors. As authors, we pledge to be explicit about how we want our own work to be cited or acknowledged. 11 | 12 | - We pledge to welcome those interested in joining the community, and realize that including people with a variety of opinions and backgrounds will only serve to enrich our community. In particular, discussions relating to pros/cons of various technologies, programming languages, and so on are welcome, but these should be done with respect, taking proactive measure to ensure that all participants are heard and feel confident that they can freely express their opinions. 13 | 14 | - We pledge to welcome questions and answer them respectfully, paying particular attention to those new to the community. We pledge to provide respectful criticisms and feedback in forums, especially in discussion threads resulting from code contributions. 15 | 16 | - We pledge to be conscientious of the perceptions of the wider community and to respond to criticism respectfully. We will strive to model behaviors that encourage productive debate and disagreement, both within our community and where we are criticized. We will treat those outside our community with the same respect as people within our community. 17 | 18 | - We pledge to help the entire community follow the code of conduct, and to not remain silent when we see violations of the code of conduct. We will take action when members of our community violate this code such as such as contacting conduct@stsci.edu (all emails sent to this address will be treated with the strictest confidence) or talking privately with the person. 19 | 20 | This code of conduct applies to all community situations online and offline, including mailing lists, forums, social media, conferences, meetings, associated social events, and one-to-one interactions. 21 | 22 | Parts of this code of conduct have been adapted from the Astropy and Numfocus codes of conduct. 23 | http://www.astropy.org/code_of_conduct.html 24 | https://www.numfocus.org/about/code-of-conduct/ -------------------------------------------------------------------------------- /jirahub/utils.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import re 3 | import logging 4 | 5 | from .entities import Source 6 | 7 | 8 | __all__ = ["UrlHelper", "isolate_regions"] 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | _GITHUB_URL_RE = re.compile(r"https://github.com/(.*)/issues/([0-9]+)") 14 | 15 | 16 | def make_github_issue_url(github_repository, github_issue_id): 17 | return f"https://github.com/{github_repository}/issues/{github_issue_id}" 18 | 19 | 20 | def extract_github_ids_from_url(github_url): 21 | match = _GITHUB_URL_RE.match(github_url) 22 | if match: 23 | return match.group(1), int(match.group(2)) 24 | else: 25 | return None, None 26 | 27 | 28 | class UrlHelper: 29 | @classmethod 30 | def from_config(cls, config): 31 | return cls(jira_server=config.jira.server, github_repository=config.github.repository) 32 | 33 | def __init__(self, jira_server, github_repository): 34 | self._jira_server = jira_server 35 | self._github_repository = github_repository 36 | 37 | def get_issue_url(self, issue=None, source=None, issue_id=None): 38 | assert issue or source and issue_id 39 | 40 | if issue: 41 | source = issue.source 42 | issue_id = issue.issue_id 43 | 44 | if source == Source.JIRA: 45 | return f"{self._jira_server}/browse/{issue_id}" 46 | else: 47 | return make_github_issue_url(self._github_repository, issue_id) 48 | 49 | def get_pull_request_url(self, pull_request_id): 50 | return f"https://github.com/{self._github_repository}/pull/{pull_request_id}" 51 | 52 | def get_comment_url(self, issue=None, comment=None, source=None, issue_id=None, comment_id=None): 53 | assert issue or source and issue_id 54 | assert comment or source and comment_id 55 | 56 | if issue: 57 | source = issue.source 58 | issue_id = issue.issue_id 59 | 60 | if comment: 61 | source = comment.source 62 | comment_id = comment.comment_id 63 | 64 | if source == Source.JIRA: 65 | return f"{self._jira_server}/browse/{issue_id}?focusedCommentId={comment_id}#comment-{comment_id}" 66 | else: 67 | return f"https://github.com/{self._github_repository}/issues/{issue_id}#issuecomment-{comment_id}" 68 | 69 | def get_user_profile_url(self, user=None, source=None, username=None): 70 | assert user or source and username 71 | 72 | if user: 73 | source = user.source 74 | username = user.username 75 | 76 | if source == Source.JIRA: 77 | return f"{self._jira_server}/secure/ViewProfile.jspa?name={urllib.parse.quote(username)}" 78 | else: 79 | return f"https://github.com/{urllib.parse.quote(username)}" 80 | 81 | 82 | def isolate_regions(regions, open_re, close_re, content_handler): 83 | new_regions = [] 84 | for content, formatted in regions: 85 | if not formatted: 86 | new_regions.append((content, formatted)) 87 | else: 88 | current_index = 0 89 | while current_index < len(content): 90 | open_match = open_re.search(content, current_index) 91 | if open_match: 92 | if open_match.start() > current_index: 93 | new_regions.append((content[current_index : open_match.start()], True)) 94 | 95 | start_index = open_match.end() 96 | close_match = close_re.search(content, start_index) 97 | if close_match: 98 | end_index = close_match.start() 99 | else: 100 | end_index = len(content) 101 | region = content_handler(content[start_index:end_index], open_match) 102 | new_regions.append(region) 103 | if close_match: 104 | current_index = close_match.end() 105 | else: 106 | current_index = len(content) 107 | else: 108 | new_regions.append((content[current_index:], True)) 109 | current_index = len(content) 110 | return new_regions 111 | -------------------------------------------------------------------------------- /jirahub/permissions.py: -------------------------------------------------------------------------------- 1 | from github import Github 2 | from github.GithubException import BadCredentialsException, UnknownObjectException 3 | from jira import JIRA 4 | from jira.exceptions import JIRAError 5 | import requests 6 | 7 | from .config import SyncFeature 8 | from . import jira, github 9 | from .entities import Source 10 | 11 | 12 | __all__ = ["check_permissions"] 13 | 14 | 15 | def check_permissions(config): 16 | errors = [] 17 | 18 | errors.extend(_check_jira_permissions(config)) 19 | errors.extend(_check_github_permissions(config)) 20 | 21 | return errors 22 | 23 | 24 | def _check_jira_permissions(config): 25 | errors = [] 26 | 27 | username = jira.get_username() 28 | if not username: 29 | errors.append("Missing JIRA username. Set the JIRAHUB_JIRA_USERNAME environment variable.") 30 | 31 | password = jira.get_password() 32 | if not password: 33 | errors.append("Missing JIRA password. Set the JIRAHUB_JIRA_PASSWORD environment variable.") 34 | 35 | if errors: 36 | return errors 37 | 38 | try: 39 | client = JIRA(config.jira.server, basic_auth=(username, password), max_retries=0) 40 | except requests.exceptions.ConnectionError: 41 | errors.append(f"Unable to communicate with JIRA server: {config.jira.server}") 42 | return errors 43 | except JIRAError: 44 | errors.append("JIRA rejected credentials. Check JIRAHUB_JIRA_USERNAME and JIRAHUB_JIRA_PASSWORD.") 45 | return errors 46 | 47 | try: 48 | perms_response = client.my_permissions(projectKey=config.jira.project_key) 49 | except JIRAError: 50 | errors.append(f"JIRA project {config.jira.project_key} does not exist.") 51 | return errors 52 | 53 | perms = {k for k, v in perms_response["permissions"].items() if v["havePermission"]} 54 | 55 | if "BROWSE_PROJECTS" not in perms: 56 | errors.append("JIRA user has not been granted the BROWSE_PROJECTS permission.") 57 | 58 | if "EDIT_ISSUES" not in perms: 59 | errors.append("JIRA user has not been granted the EDIT_ISSUES permission.") 60 | 61 | if config.jira.issue_filter and "CREATE_ISSUES" not in perms: 62 | errors.append( 63 | "c.jira.issue_filter is defined, but JIRA user has not been granted the CREATE_ISSUES permission." 64 | ) 65 | 66 | if not config.jira.notify_watchers and not perms.intersection( 67 | {"SYSTEM_ADMIN", "ADMINISTER", "ADMINISTER_PROJECTS"} 68 | ): 69 | errors.append( 70 | "c.jira.notify_watchers is False, but JIRA user has not been granted the ADMINISTER_PROJECTS permission." 71 | ) 72 | 73 | for sync_feature in SyncFeature: 74 | for permission in sync_feature.jira_permissions_required: 75 | if config.is_enabled(Source.JIRA, sync_feature) and permission not in perms: 76 | errors.append( 77 | f"c.jira.{sync_feature.key} is enabled, but JIRA user has not been granted the {permission} permission." 78 | ) 79 | 80 | return errors 81 | 82 | 83 | def _check_github_permissions(config): 84 | errors = [] 85 | 86 | token = github.get_token() 87 | if not token: 88 | errors.append("Missing GitHub access token. Set the JIRAHUB_GITHUB_TOKEN environment variable.") 89 | return errors 90 | 91 | client = Github(token) 92 | 93 | try: 94 | repo = client.get_repo(config.github.repository) 95 | except BadCredentialsException: 96 | errors.append("GitHub rejected credentials. Check JIRAHUB_GITHUB_TOKEN and try again.") 97 | return errors 98 | except UnknownObjectException: 99 | errors.append( 100 | f"GitHub repository {config.github.repository} does not exist, or user does not have read access." 101 | ) 102 | return errors 103 | 104 | if not repo.permissions.push: 105 | if config.github.issue_filter: 106 | errors.append("c.github.issue_filter is defined, but GitHub user has not been granted push permissions.") 107 | 108 | for sync_feature in SyncFeature: 109 | if config.is_enabled(Source.GITHUB, sync_feature) and sync_feature.github_push_required: 110 | errors.append( 111 | f"c.github.{sync_feature.key} is enabled, but GitHub user has not been granted push permissions." 112 | ) 113 | 114 | return errors 115 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jirahub.utils import UrlHelper, make_github_issue_url, extract_github_ids_from_url 4 | from jirahub.entities import Source 5 | 6 | from . import constants 7 | 8 | 9 | def test_make_github_issue_url(): 10 | assert make_github_issue_url("spacetelescope/jwst", 143) == "https://github.com/spacetelescope/jwst/issues/143" 11 | 12 | 13 | def test_extract_github_ids_from_url(): 14 | url = "https://github.com/spacetelescope/jwst/issues/143" 15 | github_repository, github_issue_id = extract_github_ids_from_url(url) 16 | 17 | assert github_repository == "spacetelescope/jwst" 18 | assert github_issue_id == 143 19 | 20 | 21 | def test_extract_github_ids_from_url_bad_url(): 22 | url = "https://www.zombo.com/spacetelescope/jwst/issues/143" 23 | assert extract_github_ids_from_url(url) == (None, None) 24 | 25 | 26 | class TestUrlHelper: 27 | @pytest.fixture 28 | def url_helper(self): 29 | return UrlHelper(constants.TEST_JIRA_SERVER, constants.TEST_GITHUB_REPOSITORY) 30 | 31 | def test_from_config(self, url_helper, config): 32 | url_helper_from_config = UrlHelper.from_config(config) 33 | 34 | assert url_helper.get_issue_url( 35 | source=Source.JIRA, issue_id="TEST-198" 36 | ) == url_helper_from_config.get_issue_url(source=Source.JIRA, issue_id="TEST-198") 37 | assert url_helper.get_issue_url(source=Source.GITHUB, issue_id=43) == url_helper_from_config.get_issue_url( 38 | source=Source.GITHUB, issue_id=43 39 | ) 40 | 41 | def test_get_issue_url_jira(self, url_helper, create_issue): 42 | result = url_helper.get_issue_url(source=Source.JIRA, issue_id="TEST-489") 43 | assert result == "https://test.jira.server/browse/TEST-489" 44 | 45 | issue = create_issue(source=Source.JIRA) 46 | result = url_helper.get_issue_url(issue=issue) 47 | assert result == f"https://test.jira.server/browse/{issue.issue_id}" 48 | 49 | def test_get_issue_url_github(self, url_helper, create_issue): 50 | result = url_helper.get_issue_url(source=Source.GITHUB, issue_id=489) 51 | assert result == "https://github.com/testing/test-repo/issues/489" 52 | 53 | issue = create_issue(source=Source.GITHUB) 54 | result = url_helper.get_issue_url(issue=issue) 55 | assert result == f"https://github.com/testing/test-repo/issues/{issue.issue_id}" 56 | 57 | def test_get_pull_request_url(self, url_helper): 58 | result = url_helper.get_pull_request_url(586) 59 | assert result == "https://github.com/testing/test-repo/pull/586" 60 | 61 | def test_get_comment_url_jira(self, url_helper, create_issue, create_comment): 62 | result = url_helper.get_comment_url(source=Source.JIRA, issue_id="TEST-489", comment_id=14938) 63 | assert result == "https://test.jira.server/browse/TEST-489?focusedCommentId=14938#comment-14938" 64 | 65 | issue = create_issue(source=Source.JIRA) 66 | comment = create_comment(source=Source.JIRA) 67 | result = url_helper.get_comment_url(issue=issue, comment=comment) 68 | assert ( 69 | result 70 | == f"https://test.jira.server/browse/{issue.issue_id}?focusedCommentId={comment.comment_id}#comment-{comment.comment_id}" 71 | ) 72 | 73 | def test_get_comment_url_github(self, url_helper, create_issue, create_comment): 74 | result = url_helper.get_comment_url(source=Source.GITHUB, issue_id=489, comment_id=14938) 75 | assert result == "https://github.com/testing/test-repo/issues/489#issuecomment-14938" 76 | 77 | issue = create_issue(source=Source.GITHUB) 78 | comment = create_comment(source=Source.GITHUB) 79 | result = url_helper.get_comment_url(issue=issue, comment=comment) 80 | assert ( 81 | result == f"https://github.com/testing/test-repo/issues/{issue.issue_id}#issuecomment-{comment.comment_id}" 82 | ) 83 | 84 | def test_get_user_profile_url_jira(self, url_helper, create_user): 85 | result = url_helper.get_user_profile_url(source=Source.JIRA, username="testuser123@example.com") 86 | assert result == "https://test.jira.server/secure/ViewProfile.jspa?name=testuser123%40example.com" 87 | 88 | user = create_user(source=Source.JIRA) 89 | result = url_helper.get_user_profile_url(user=user) 90 | assert result == f"https://test.jira.server/secure/ViewProfile.jspa?name={user.username}" 91 | 92 | def test_get_user_profile_url_github(self, url_helper, create_user): 93 | result = url_helper.get_user_profile_url(source=Source.GITHUB, username="testuser123@example.com") 94 | assert result == "https://github.com/testuser123%40example.com" 95 | 96 | user = create_user(source=Source.GITHUB) 97 | result = url_helper.get_user_profile_url(user=user) 98 | assert result == f"https://github.com/{user.username}" 99 | -------------------------------------------------------------------------------- /jirahub/config.py: -------------------------------------------------------------------------------- 1 | import importlib.resources as importlib_resources 2 | import logging 3 | from enum import Enum 4 | from dataclasses import dataclass, field 5 | from typing import List, Callable, Tuple 6 | from pathlib import Path 7 | from collections.abc import Iterable 8 | import re 9 | 10 | from . import resources as jirahub_resources 11 | from .entities import Source, Issue, Comment 12 | 13 | 14 | __all__ = ["load_config", "generate_config_template"] 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class SyncFeature(Enum): 21 | SYNC_COMMENTS = ({"ADD_COMMENTS", "DELETE_OWN_COMMENTS", "EDIT_OWN_COMMENTS"}, False) 22 | SYNC_STATUS = ({"CLOSE_ISSUES", "RESOLVE_ISSUES", "TRANSITION_ISSUES"}, True) 23 | SYNC_LABELS = ({"EDIT_ISSUES"}, True) 24 | SYNC_MILESTONES = ({"EDIT_ISSUES", "RESOLVE_ISSUES"}, True) 25 | 26 | def __init__(self, jira_permissions_required, github_push_required): 27 | self.jira_permissions_required = jira_permissions_required 28 | self.github_push_required = github_push_required 29 | 30 | @property 31 | def key(self): 32 | return self.name.lower() 33 | 34 | def __str__(self): 35 | return self.name.lower() 36 | 37 | 38 | @dataclass 39 | class JiraConfig: 40 | server: str = None 41 | project_key: str = None 42 | github_issue_url_field_id: int = None 43 | jirahub_metadata_field_id: int = None 44 | closed_statuses: List[str] = field(default_factory=lambda: ["closed"]) 45 | close_status: str = "Closed" 46 | reopen_status: str = "Reopened" 47 | open_status: str = None 48 | max_retries: int = 3 49 | notify_watchers: bool = True 50 | sync_comments: bool = False 51 | sync_status: bool = False 52 | sync_labels: bool = False 53 | sync_milestones: bool = False 54 | create_tracking_comment: bool = False 55 | redact_patterns: List[re.Pattern] = field(default_factory=list) 56 | issue_title_formatter: Callable[[Issue, str], str] = None 57 | issue_body_formatter: Callable[[Issue, str], str] = None 58 | comment_body_formatter: Callable[[Issue, Comment, str], str] = None 59 | issue_filter: Callable[[Issue], bool] = None 60 | # The callable may optionally receive the IssueSync instance as a third argument. 61 | before_issue_create: List[Callable[[Issue, dict], dict]] = field(default_factory=list) 62 | 63 | 64 | @dataclass 65 | class GithubConfig: 66 | repository: str = None 67 | max_retries: int = 3 68 | sync_comments: bool = False 69 | sync_status: bool = False 70 | sync_labels: bool = False 71 | sync_milestones: bool = False 72 | create_tracking_comment: bool = False 73 | redact_patterns: List[re.Pattern] = field(default_factory=list) 74 | issue_title_formatter: Callable[[Issue, str], str] = None 75 | issue_body_formatter: Callable[[Issue, str], str] = None 76 | comment_body_formatter: Callable[[Issue, Comment, str], str] = None 77 | issue_filter: Callable[[Issue], bool] = None 78 | # The callable may optionally receive the IssueSync instance as a third argument. 79 | before_issue_create: List[Callable[[Issue, dict], dict]] = field(default_factory=list) 80 | 81 | 82 | @dataclass 83 | class JirahubConfig: 84 | jira: JiraConfig = field(default_factory=JiraConfig) 85 | github: GithubConfig = field(default_factory=GithubConfig) 86 | # The callable may optionally receive the IssueSync instance as a fourth argument. 87 | before_issue_update: List[Callable[[Issue, dict, Issue, dict], Tuple[dict, dict]]] = field(default_factory=list) 88 | 89 | def get_source_config(self, source): 90 | if source == Source.JIRA: 91 | return self.jira 92 | else: 93 | return self.github 94 | 95 | def is_enabled(self, source, sync_feature): 96 | return getattr(self.get_source_config(source), sync_feature.key) 97 | 98 | 99 | def load_config(paths): 100 | config = JirahubConfig() 101 | 102 | if not isinstance(paths, Iterable) or isinstance(paths, str): 103 | paths = [paths] 104 | 105 | for path in paths: 106 | p = Path(path) 107 | if not (p.exists() and p.is_file()): 108 | raise FileNotFoundError(f"Config file at {path} not found") 109 | with p.open() as file: 110 | exec(file.read(), {}, {"c": config}) 111 | 112 | validate_config(config) 113 | 114 | return config 115 | 116 | 117 | def generate_config_template(): 118 | return importlib_resources.read_text(jirahub_resources, "config_template.py") 119 | 120 | 121 | _REQUIRED_PARAMETERS = { 122 | ("jira.server", "JIRA server"), 123 | ("jira.project_key", "JIRA project key"), 124 | ("jira.github_issue_url_field_id", "JIRA issue URL field ID"), 125 | ("jira.jirahub_metadata_field_id", "JIRA metadata field ID"), 126 | ("github.repository", "GitHub repository"), 127 | } 128 | 129 | 130 | def validate_config(config): 131 | for param, description in _REQUIRED_PARAMETERS: 132 | value = config 133 | for part in param.split("."): 134 | value = getattr(value, part) 135 | if not value: 136 | raise RuntimeError(f"Missing {description}, please set c.{param} in your config file") 137 | -------------------------------------------------------------------------------- /tests/test_command_line.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from datetime import datetime, timedelta 4 | import json 5 | 6 | from jirahub import command_line 7 | from jirahub.jirahub import IssueSync 8 | from jirahub.entities import Source 9 | 10 | from . import mocks 11 | 12 | 13 | CONFIG_DIR = Path(__file__).parent / "configs" 14 | CONFIG_PATH = CONFIG_DIR / "full_config.py" 15 | BASE_CONFIG_PATH = CONFIG_DIR / "fragment_config_base.py" 16 | OVERRIDES_CONFIG_PATH = CONFIG_DIR / "fragment_config_overrides.py" 17 | MISSING_CONFIG_PATH = CONFIG_DIR / "missing.py" 18 | BAD_CONFIG_PATH = CONFIG_DIR / "bad_config.py" 19 | FAILING_CONFIG_PATH = CONFIG_DIR / "fragment_config_failing.py" 20 | 21 | 22 | def monkey_patch_args(monkeypatch, args): 23 | monkeypatch.setattr(sys, "argv", args) 24 | 25 | 26 | def test_main_generate_config(monkeypatch): 27 | monkey_patch_args(monkeypatch, ["jirahub", "generate-config"]) 28 | assert command_line.main() == 0 29 | 30 | 31 | def test_main_check_permissions(monkeypatch): 32 | monkey_patch_args(monkeypatch, ["jirahub", "check-permissions", str(CONFIG_PATH)]) 33 | assert command_line.main() == 0 34 | 35 | 36 | def test_main_check_permissions_invalid(monkeypatch): 37 | monkey_patch_args(monkeypatch, ["jirahub", "check-permissions", str(CONFIG_PATH)]) 38 | mocks.MockJIRA.valid_project_keys = [] 39 | assert command_line.main() == 1 40 | 41 | 42 | def test_main_check_permissions_missing_config(monkeypatch): 43 | monkey_patch_args(monkeypatch, ["jirahub", "check-permissions", str(MISSING_CONFIG_PATH)]) 44 | assert command_line.main() == 1 45 | 46 | 47 | def test_main_check_permissions_exception(monkeypatch): 48 | monkey_patch_args(monkeypatch, ["jirahub", "check-permissions", str(CONFIG_PATH)]) 49 | 50 | def broken_permissions(config): 51 | raise Exception("nope") 52 | 53 | monkeypatch.setattr(command_line, "check_permissions", broken_permissions) 54 | assert command_line.main() == 1 55 | 56 | 57 | def test_main_sync(monkeypatch, tmp_path): 58 | monkey_patch_args(monkeypatch, ["jirahub", "sync", str(CONFIG_PATH)]) 59 | assert command_line.main() == 0 60 | 61 | monkey_patch_args(monkeypatch, ["jirahub", "sync", str(BASE_CONFIG_PATH), str(OVERRIDES_CONFIG_PATH)]) 62 | assert command_line.main() == 0 63 | 64 | monkey_patch_args(monkeypatch, ["jirahub", "sync", "-v", str(CONFIG_PATH)]) 65 | assert command_line.main() == 0 66 | 67 | monkey_patch_args(monkeypatch, ["jirahub", "sync", "--min-updated-at", "1983-11-20T11:00:00", str(CONFIG_PATH)]) 68 | assert command_line.main() == 0 69 | 70 | state_path = tmp_path / "state.json" 71 | with open(state_path, "w") as file: 72 | file.write(json.dumps({"min_updated_at": "2018-01-01T01:23:45"})) 73 | monkey_patch_args(monkeypatch, ["jirahub", "sync", "--state-path", str(state_path), str(CONFIG_PATH)]) 74 | assert command_line.main() == 0 75 | with open(state_path, "r") as file: 76 | new_state = json.loads(file.read()) 77 | new_placeholder = datetime.fromisoformat(new_state["min_updated_at"]) 78 | assert abs(new_placeholder - datetime.utcnow()) < timedelta(seconds=1) 79 | 80 | missing_state_path = tmp_path / "missing_state.json" 81 | monkey_patch_args(monkeypatch, ["jirahub", "sync", "--state-path", str(missing_state_path), str(CONFIG_PATH)]) 82 | assert command_line.main() == 0 83 | with open(missing_state_path, "r") as file: 84 | new_state = json.loads(file.read()) 85 | new_placeholder = datetime.fromisoformat(new_state["min_updated_at"]) 86 | assert abs(new_placeholder - datetime.utcnow()) < timedelta(seconds=1) 87 | 88 | 89 | def test_main_sync_retry_issues(monkeypatch, tmp_path, jira_client, github_client, create_issue): 90 | jira_issue = create_issue(Source.JIRA) 91 | jira_client.issues = [jira_issue] 92 | 93 | github_issue = create_issue(Source.GITHUB) 94 | github_client.issues = [github_issue] 95 | 96 | class MockIssueSync: 97 | @classmethod 98 | def from_config(cls, config, dry_run=False): 99 | return IssueSync(config=config, jira_client=jira_client, github_client=github_client, dry_run=dry_run) 100 | 101 | monkeypatch.setattr(command_line, "IssueSync", MockIssueSync) 102 | 103 | state_path = tmp_path / "state.json" 104 | 105 | monkey_patch_args( 106 | monkeypatch, ["jirahub", "sync", "--state-path", str(state_path), str(CONFIG_PATH), str(FAILING_CONFIG_PATH)] 107 | ) 108 | assert command_line.main() == 0 109 | 110 | with open(state_path, "r") as file: 111 | state = json.loads(file.read()) 112 | retry_issues = {(Source[i["source"]], i["issue_id"]) for i in state["retry_issues"]} 113 | assert (Source.JIRA, jira_issue.issue_id) in retry_issues 114 | assert (Source.GITHUB, github_issue.issue_id) in retry_issues 115 | 116 | monkey_patch_args(monkeypatch, ["jirahub", "sync", "--state-path", str(state_path), str(CONFIG_PATH)]) 117 | assert command_line.main() == 0 118 | 119 | with open(state_path, "r") as file: 120 | state = json.loads(file.read()) 121 | assert len(state["retry_issues"]) == 0 122 | 123 | 124 | def test_main_sync_missing_config(monkeypatch): 125 | monkey_patch_args(monkeypatch, ["jirahub", "sync", str(MISSING_CONFIG_PATH)]) 126 | assert command_line.main() == 1 127 | 128 | 129 | def test_main_sync_failure(monkeypatch): 130 | monkey_patch_args(monkeypatch, ["jirahub", "sync", str(BAD_CONFIG_PATH)]) 131 | assert command_line.main() == 1 132 | -------------------------------------------------------------------------------- /jirahub/resources/config_template.py: -------------------------------------------------------------------------------- 1 | # jirahub configuration file 2 | 3 | # URL of JIRA deployment (required) 4 | c.jira.server = 5 | 6 | # JIRA project key (required) 7 | c.jira.project_key = 8 | 9 | # Integer ID of the JIRA custom field that stores the GitHub URL of a 10 | # linked issue. 11 | # (required) 12 | c.jira.github_issue_url_field_id = 13 | 14 | # Integer ID of the JIRA custom field that stores jirahub metadata. 15 | # (required) 16 | c.jira.jirahub_metadata_field_id = 17 | 18 | # JIRA statuses that will be considered closed 19 | #c.jira.closed_statuses = ["closed"] 20 | 21 | # JIRA status to set when issue is closed 22 | #c.jira.close_status = "Closed" 23 | 24 | # JIRA status to set when issue is re-opened 25 | #c.jira.reopen_status = "Reopened" 26 | 27 | # JIRA status to set when an issue is first opened 28 | # (set to None to use your project's default) 29 | #c.jira.open_status = None 30 | 31 | # Maximum number of retries on JIRA request failure 32 | #c.jira.max_retries = 3 33 | 34 | # Notify watchers when an issue is updated by the bot 35 | #c.jira.notify_watchers = True 36 | 37 | # Create JIRA comments from GitHub comments 38 | #c.jira.sync_comments = False 39 | 40 | # Set the status of the JIRA issue based on the GitHub open/closed status 41 | #c.jira.sync_status = False 42 | 43 | # Copy labels from GitHub to JIRA 44 | #c.jira.sync_labels = False 45 | 46 | # Copy milestone from GitHub to JIRA's fixVersions field 47 | #c.jira.sync_milestones = False 48 | 49 | # Create a comment on a linked JIRA issue (not owned by the bot) 50 | # containing a link back to GitHub. 51 | #c.jira.create_tracking_comment = False 52 | 53 | # Regular expressions whose matches will be redacted from issue titles, 54 | # issue bodies, or comment bodies copied over from GitHub. 55 | # Must be instances of re.Pattern. 56 | #c.jira.redact_patterns = [] 57 | 58 | # Callable that transforms the GitHub issue title before creating/updating 59 | # it in JIRA. Accepts two arguments, the original GitHub Issue and the 60 | # redacted/reformatted title. The callable must return the transformed 61 | # title as a string. 62 | #c.jira.issue_title_formatter = None 63 | 64 | # Callable that transforms the GitHub issue body before creating/updating 65 | # it in JIRA. Accepts two arguments, the original GitHub Issue and the 66 | # redacted/reformatted body. The callable must return the transformed 67 | # body as a string. 68 | #c.jira.issue_body_formatter = None 69 | 70 | # Callable that transforms the GitHub comment body before creating/updating 71 | # it in JIRA. Accepts three arguments, the original GitHub Issue and Comment, 72 | # and the redacted/reformatted body. The callable must return the transformed 73 | # body as a string. 74 | # c.jira.comment_body_formatter = None 75 | 76 | # Callable that selects GitHub issues to create in JIRA. Should accept 77 | # a single argument, the original GitHub Issue, and return True to create 78 | # the issue in JIRA, False to ignore it. Set to None to disable creating 79 | # JIRA issues. 80 | #c.jira.issue_filter = None 81 | 82 | # List of callables that transform the fields used to create a new JIRA issue. 83 | # Each callable should accept two arguments, the original GitHub Issue, and 84 | # an initial dict of fields. The callable must return the transformed fields 85 | # as a dict. 86 | #c.jira.before_issue_create = [] 87 | 88 | # GitHub repository (e.g., spacetelescope/jwst) (required) 89 | c.github.repository = 90 | 91 | # Maximum number of retries on GitHub request failure 92 | #c.github.max_retries = 3 93 | 94 | # Create GitHub comments from JIRA comments 95 | #c.github.sync_comments = False 96 | 97 | # Set the GitHub open/closed state based on the JIRA status 98 | #c.github.sync_status = False 99 | 100 | # Copy labels from JIRA to GitHub 101 | #c.github.sync_labels = False 102 | 103 | # Copy JIRA's fixVersions field to GitHub's milestone 104 | #c.github.sync_milestones = False 105 | 106 | # Create a comment on a linked GitHub issue (not owned by the bot) 107 | # containing a link back to JIRA. 108 | #c.github.create_tracking_comment = False 109 | 110 | # Regular expressions whose matches will be redacted from issue titles, 111 | # issue bodies, or comment bodies copied over from JIRA. 112 | # Must be instances of re.Pattern. 113 | #c.github.redact_patterns = [] 114 | 115 | # Callable that transforms the JIRA issue title before creating/updating 116 | # it in GitHub. Accepts two arguments, the original JIRA Issue and the 117 | # redacted/reformatted title. The callable must return the transformed 118 | # title as a string. 119 | #c.github.issue_title_formatter = None 120 | 121 | # Callable that transforms the JIRA issue body before creating/updating 122 | # it in GitHub. Accepts two arguments, the original JIRA Issue and the 123 | # redacted/reformatted body. The callable must return the transformed 124 | # body as a string. 125 | #c.github.issue_body_formatter = None 126 | 127 | # Callable that transforms the JIRA comment body before creating/updating 128 | # it in GitHub. Accepts three arguments, the original JIRA Issue and Comment, 129 | # and the redacted/reformatted body. The callable must return the transformed 130 | # body as a string. 131 | # c.github.comment_body_formatter = None 132 | 133 | # Callable that selects JIRA issues to create in GitHub. Should accept 134 | # a single argument (instance of jirahub.entities.Issue) and return 135 | # True to create the issue in GitHub, False to ignore it. Set to None 136 | # to disable creating GitHub issues. 137 | #c.github.issue_filter = None 138 | 139 | # List of callables that transform the fields used to create a new GitHub issue. 140 | # Each callable should accept two arguments, the original JIRA Issue, and 141 | # an initial dict of fields. The callable must return the transformed fields 142 | # as a dict. 143 | #c.github.before_issue_create = [] 144 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jirahub.permissions import check_permissions 4 | from jirahub.config import SyncFeature 5 | 6 | from . import mocks 7 | 8 | 9 | def test_check_permissions(config): 10 | errors = check_permissions(config) 11 | assert len(errors) == 0 12 | 13 | 14 | @pytest.mark.parametrize("env_key", ["JIRAHUB_JIRA_USERNAME", "JIRAHUB_JIRA_PASSWORD", "JIRAHUB_GITHUB_TOKEN"]) 15 | def test_check_permissions_missing_env(monkeypatch, config, env_key): 16 | monkeypatch.delenv(env_key) 17 | 18 | errors = check_permissions(config) 19 | assert len(errors) == 1 20 | assert env_key in errors[0] 21 | 22 | 23 | def test_check_permissions_bad_jira_credentials(config): 24 | mocks.MockJIRA.valid_basic_auths = [] 25 | 26 | errors = check_permissions(config) 27 | assert len(errors) == 1 28 | assert "JIRA rejected credentials" in errors[0] 29 | 30 | 31 | def test_check_permissions_bad_jira_server(config): 32 | mocks.MockJIRA.valid_servers = [] 33 | 34 | errors = check_permissions(config) 35 | assert len(errors) == 1 36 | assert "Unable to communicate with JIRA server" in errors[0] 37 | 38 | 39 | def test_check_permissions_missing_jira_project(config): 40 | mocks.MockJIRA.valid_project_keys = [] 41 | 42 | errors = check_permissions(config) 43 | assert len(errors) == 1 44 | assert f"JIRA project {config.jira.project_key} does not exist" in errors[0] 45 | 46 | 47 | def test_check_permissions_missing_jira_browse_projects(config): 48 | mocks.MockJIRA.permissions = [p for p in mocks.MockJIRA.ALL_PERMISSIONS if p != "BROWSE_PROJECTS"] 49 | 50 | errors = check_permissions(config) 51 | assert len(errors) == 1 52 | assert "BROWSE_PROJECTS" in errors[0] 53 | 54 | 55 | def test_check_permissions_missing_jira_edit_issues(config): 56 | mocks.MockJIRA.permissions = [p for p in mocks.MockJIRA.ALL_PERMISSIONS if p != "EDIT_ISSUES"] 57 | 58 | errors = check_permissions(config) 59 | assert len(errors) == 1 60 | assert "EDIT_ISSUES" in errors[0] 61 | 62 | 63 | def test_check_permissions_jira_issue_filter(config): 64 | config.jira.issue_filter = lambda _: True 65 | 66 | mocks.MockJIRA.permissions = mocks.MockJIRA.ALL_PERMISSIONS 67 | errors = check_permissions(config) 68 | assert len(errors) == 0 69 | 70 | mocks.MockJIRA.permissions = [p for p in mocks.MockJIRA.ALL_PERMISSIONS if p != "CREATE_ISSUES"] 71 | errors = check_permissions(config) 72 | assert len(errors) == 1 73 | assert "CREATE_ISSUES" in errors[0] 74 | assert "c.jira.issue_filter" in errors[0] 75 | 76 | 77 | def test_check_permissions_notify_watchers(config): 78 | config.jira.notify_watchers = False 79 | 80 | mocks.MockJIRA.permissions = mocks.MockJIRA.ALL_PERMISSIONS 81 | errors = check_permissions(config) 82 | assert len(errors) == 0 83 | 84 | mocks.MockJIRA.permissions = [ 85 | p for p in mocks.MockJIRA.ALL_PERMISSIONS if p not in {"ADMINISTER", "ADMINISTER_PROJECTS", "SYSTEM_ADMIN"} 86 | ] 87 | errors = check_permissions(config) 88 | assert len(errors) == 1 89 | assert "ADMINISTER_PROJECTS" in errors[0] 90 | assert "c.jira.notify_watchers" in errors[0] 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "sync_feature, permission", 95 | [ 96 | (SyncFeature.SYNC_COMMENTS, "ADD_COMMENTS"), 97 | (SyncFeature.SYNC_COMMENTS, "DELETE_OWN_COMMENTS"), 98 | (SyncFeature.SYNC_COMMENTS, "EDIT_OWN_COMMENTS"), 99 | (SyncFeature.SYNC_STATUS, "CLOSE_ISSUES"), 100 | (SyncFeature.SYNC_STATUS, "RESOLVE_ISSUES"), 101 | (SyncFeature.SYNC_LABELS, "EDIT_ISSUES"), 102 | (SyncFeature.SYNC_MILESTONES, "EDIT_ISSUES"), 103 | (SyncFeature.SYNC_MILESTONES, "RESOLVE_ISSUES"), 104 | ], 105 | ) 106 | @pytest.mark.parametrize( 107 | "feature_enabled, has_permission, error_expected", 108 | [(True, True, False), (True, False, True), (False, True, False), (False, False, False)], 109 | ) 110 | def test_check_permissions_jira_sync_feature( 111 | config, sync_feature, permission, feature_enabled, has_permission, error_expected 112 | ): 113 | setattr(config.jira, sync_feature.key, feature_enabled) 114 | 115 | if not has_permission: 116 | mocks.MockJIRA.permissions = [p for p in mocks.MockJIRA.ALL_PERMISSIONS if not p == permission] 117 | 118 | errors = check_permissions(config) 119 | 120 | has_error = any(e for e in errors if f"c.jira.{sync_feature.key}" in e and permission in e) 121 | assert error_expected == has_error 122 | 123 | 124 | def test_check_permissions_bad_github_credentials(config): 125 | mocks.MockGithub.valid_tokens = [] 126 | 127 | errors = check_permissions(config) 128 | assert len(errors) == 1 129 | assert "GitHub rejected credentials" in errors[0] 130 | 131 | 132 | def test_check_permissions_missing_github_repo(config): 133 | mocks.MockGithub.repositories = [] 134 | 135 | errors = check_permissions(config) 136 | assert len(errors) == 1 137 | assert "GitHub repository" in errors[0] 138 | 139 | 140 | def test_check_permissions_github_issue_filter(config): 141 | config.github.issue_filter = lambda _: True 142 | 143 | for repo in mocks.MockGithub.repositories: 144 | repo.permissions = mocks.MockGithubPermissions(push=True) 145 | 146 | errors = check_permissions(config) 147 | assert len(errors) == 0 148 | 149 | for repo in mocks.MockGithub.repositories: 150 | repo.permissions = mocks.MockGithubPermissions(push=False) 151 | 152 | errors = check_permissions(config) 153 | assert len(errors) == 1 154 | assert "c.github.issue_filter" in errors[0] 155 | 156 | 157 | @pytest.mark.parametrize( 158 | "sync_feature, push_required", 159 | [ 160 | (SyncFeature.SYNC_COMMENTS, False), 161 | (SyncFeature.SYNC_STATUS, True), 162 | (SyncFeature.SYNC_LABELS, True), 163 | (SyncFeature.SYNC_MILESTONES, True), 164 | ], 165 | ) 166 | @pytest.mark.parametrize("feature_enabled, has_push", [(True, True), (True, False), (False, True), (False, False)]) 167 | def test_check_permissions_github_sync_feature(config, sync_feature, push_required, feature_enabled, has_push): 168 | setattr(config.github, sync_feature.key, feature_enabled) 169 | 170 | for repo in mocks.MockGithub.repositories: 171 | repo.permissions = mocks.MockGithubPermissions(push=has_push) 172 | 173 | errors = check_permissions(config) 174 | 175 | if feature_enabled and push_required and not has_push: 176 | assert len(errors) == 1 177 | assert str(sync_feature) in errors[0] 178 | else: 179 | assert len(errors) == 0 180 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pathlib import Path 3 | import re 4 | import copy 5 | 6 | import jirahub 7 | from jirahub.config import load_config, generate_config_template, SyncFeature, validate_config, _REQUIRED_PARAMETERS 8 | from jirahub.entities import Source 9 | 10 | 11 | CONFIG_PATH = Path(__file__).parent / "configs" 12 | 13 | 14 | def test_load_config_minimal(): 15 | config = load_config(CONFIG_PATH / "minimal_config.py") 16 | 17 | assert config.before_issue_update == [] 18 | 19 | assert config.jira.server == "https://test.jira.server" 20 | assert config.jira.project_key == "TEST" 21 | assert config.jira.github_issue_url_field_id == 12345 22 | assert config.jira.jirahub_metadata_field_id == 67890 23 | assert config.jira.closed_statuses == ["closed"] 24 | assert config.jira.close_status == "Closed" 25 | assert config.jira.reopen_status == "Reopened" 26 | assert config.jira.open_status is None 27 | assert config.jira.max_retries == 3 28 | assert config.jira.notify_watchers is True 29 | assert config.jira.sync_comments is False 30 | assert config.jira.sync_status is False 31 | assert config.jira.sync_labels is False 32 | assert config.jira.sync_milestones is False 33 | assert config.jira.create_tracking_comment is False 34 | assert config.jira.redact_patterns == [] 35 | assert config.jira.issue_title_formatter is None 36 | assert config.jira.issue_body_formatter is None 37 | assert config.jira.comment_body_formatter is None 38 | assert config.jira.issue_filter is None 39 | assert config.jira.before_issue_create == [] 40 | 41 | assert config.github.repository == "testing/test-repo" 42 | assert config.github.max_retries == 3 43 | assert config.github.sync_comments is False 44 | assert config.github.sync_status is False 45 | assert config.github.sync_labels is False 46 | assert config.github.sync_milestones is False 47 | assert config.github.create_tracking_comment is False 48 | assert config.github.redact_patterns == [] 49 | assert config.github.issue_title_formatter is None 50 | assert config.github.issue_body_formatter is None 51 | assert config.github.comment_body_formatter is None 52 | assert config.github.issue_filter is None 53 | assert config.github.before_issue_create == [] 54 | 55 | 56 | def test_load_config_full(): 57 | config = load_config(CONFIG_PATH / "full_config.py") 58 | 59 | assert callable(config.before_issue_update[0]) 60 | 61 | assert config.jira.server == "https://test.jira.server" 62 | assert config.jira.project_key == "TEST" 63 | assert config.jira.github_issue_url_field_id == 12345 64 | assert config.jira.jirahub_metadata_field_id == 67890 65 | assert config.jira.closed_statuses == ["Closed", "Done"] 66 | assert config.jira.close_status == "Done" 67 | assert config.jira.reopen_status == "Ready" 68 | assert config.jira.open_status == "Open" 69 | assert config.jira.max_retries == 5 70 | assert config.jira.notify_watchers is False 71 | assert config.jira.sync_comments is True 72 | assert config.jira.sync_status is True 73 | assert config.jira.sync_labels is True 74 | assert config.jira.sync_milestones is True 75 | assert config.jira.create_tracking_comment is True 76 | assert config.jira.redact_patterns == [re.compile(r"(?<=secret GitHub data: ).+?\b")] 77 | assert callable(config.jira.issue_title_formatter) 78 | assert callable(config.jira.issue_body_formatter) 79 | assert callable(config.jira.comment_body_formatter) 80 | assert callable(config.jira.issue_filter) 81 | assert callable(config.jira.before_issue_create[0]) 82 | 83 | assert config.github.repository == "testing/test-repo" 84 | assert config.github.max_retries == 10 85 | assert config.github.sync_comments is True 86 | assert config.github.sync_status is True 87 | assert config.github.sync_labels is True 88 | assert config.github.sync_milestones is True 89 | assert config.github.create_tracking_comment is True 90 | assert config.github.redact_patterns == [re.compile(r"(?<=secret JIRA data: ).+?\b")] 91 | assert callable(config.github.issue_title_formatter) 92 | assert callable(config.github.issue_body_formatter) 93 | assert callable(config.github.comment_body_formatter) 94 | assert callable(config.github.issue_filter) 95 | assert callable(config.github.before_issue_create[0]) 96 | 97 | 98 | def test_load_config_multiple(): 99 | config = load_config([CONFIG_PATH / "fragment_config_base.py", CONFIG_PATH / "fragment_config_overrides.py"]) 100 | 101 | # From the base config 102 | assert config.jira.server == "https://test.jira.server" 103 | assert callable(config.jira.issue_filter) 104 | 105 | # From the overrides config 106 | assert config.jira.project_key == "TEST" 107 | assert config.jira.max_retries == 7 108 | assert config.github.repository == "testing/test-repo" 109 | assert config.jira.sync_milestones is False 110 | 111 | 112 | def test_load_config_missing_file(): 113 | with pytest.raises(FileNotFoundError): 114 | load_config([CONFIG_PATH / "full_config.py", CONFIG_PATH / "missing_file.py"]) 115 | 116 | 117 | def test_load_config_incomplete(): 118 | with pytest.raises(RuntimeError): 119 | load_config(CONFIG_PATH / "incomplete_config.py") 120 | 121 | 122 | def test_generate_config_template(): 123 | path = Path(jirahub.__file__).parent / "resources" / "config_template.py" 124 | with path.open("r") as file: 125 | expected = file.read() 126 | 127 | assert generate_config_template() == expected 128 | 129 | 130 | def test_validate_config(config): 131 | # Initially, no exceptions 132 | validate_config(config) 133 | 134 | for param, _ in _REQUIRED_PARAMETERS: 135 | invalid_config = copy.deepcopy(config) 136 | parts = param.split(".") 137 | setattr(getattr(invalid_config, parts[0]), parts[1], None) 138 | with pytest.raises(RuntimeError): 139 | validate_config(invalid_config) 140 | 141 | 142 | class TestJirahubConfig: 143 | def test_get_source_config(self, config): 144 | assert config.get_source_config(Source.JIRA) == config.jira 145 | assert config.get_source_config(Source.GITHUB) == config.github 146 | 147 | @pytest.mark.parametrize("source", list(Source)) 148 | @pytest.mark.parametrize("sync_feature", list(SyncFeature)) 149 | @pytest.mark.parametrize("enabled", [True, False]) 150 | def test_is_enabled(self, config, source, sync_feature, enabled): 151 | setattr(config.get_source_config(source), sync_feature.key, enabled) 152 | assert config.is_enabled(source, sync_feature) is enabled 153 | -------------------------------------------------------------------------------- /jirahub/command_line.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from argparse import ArgumentParser 4 | import traceback 5 | import logging 6 | from datetime import datetime, timezone 7 | import json 8 | 9 | from .config import load_config, generate_config_template 10 | from .permissions import check_permissions 11 | from .jirahub import IssueSync 12 | from .entities import Source 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def _parse_args(): 19 | parent_parser = ArgumentParser() 20 | parent_parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose log messages") 21 | 22 | parser = ArgumentParser(description="GitHub/JIRA issue sync tool", parents=[parent_parser], add_help=False) 23 | 24 | subparsers = parser.add_subparsers(dest="command", help="selected command") 25 | subparsers.required = True 26 | 27 | sync_parser = subparsers.add_parser("sync", description="Perform sync", parents=[parent_parser], add_help=False) 28 | sync_parser.add_argument("config_path", nargs="+", help="path to jirahub config file", metavar="config-path") 29 | 30 | placeholder_group = sync_parser.add_mutually_exclusive_group() 31 | min_updated_at_help = ( 32 | "consider issues updated after this timestamp " 33 | "(format is ISO-8601 in UTC with no timezone suffix, e.g., " 34 | "1983-11-20T11:00:00" 35 | ) 36 | placeholder_group.add_argument("--min-updated-at", help=min_updated_at_help) 37 | placeholder_group.add_argument("--state-path", help="path to JSON file containing state from previous run") 38 | 39 | sync_parser.add_argument("--dry-run", action="store_true", help="query but do not make changes to GitHub or JIRA") 40 | 41 | permissions_parser = subparsers.add_parser( 42 | "check-permissions", 43 | description="Check GitHub and JIRA credentials and permissions", 44 | parents=[parent_parser], 45 | add_help=False, 46 | ) 47 | permissions_parser.add_argument("config_path", nargs="+", help="path to jirahub config file", metavar="config-path") 48 | 49 | subparsers.add_parser( 50 | "generate-config", description="Print config file template to stdout", parents=[parent_parser], add_help=False 51 | ) 52 | 53 | return parser.parse_args() 54 | 55 | 56 | def main(): 57 | args = _parse_args() 58 | 59 | _configure_logging(args) 60 | 61 | if args.command == "sync": 62 | return _handle_sync(args) 63 | elif args.command == "generate-config": 64 | return _handle_generate_config(args) 65 | elif args.command == "check-permissions": 66 | return _handle_check_permissions(args) 67 | 68 | 69 | def _configure_logging(args): 70 | handler = logging.StreamHandler() 71 | 72 | if args.verbose: 73 | handler.setLevel(logging.INFO) 74 | logging.getLogger("jirahub").setLevel(logging.INFO) 75 | else: 76 | handler.setLevel(logging.WARNING) 77 | 78 | formatter = logging.Formatter("%(asctime)s - %(levelname)-7s - %(name)s - %(message)s") 79 | handler.setFormatter(formatter) 80 | 81 | logging.getLogger().addHandler(handler) 82 | 83 | 84 | def _handle_sync(args): 85 | try: 86 | config = load_config(args.config_path) 87 | except Exception: 88 | _print_error("Failed parsing config file:") 89 | traceback.print_exc(file=sys.stderr) 90 | return 1 91 | 92 | if args.min_updated_at: 93 | min_updated_at = datetime.fromisoformat(args.min_updated_at).replace(tzinfo=timezone.utc) 94 | retry_issues = [] 95 | elif args.state_path: 96 | min_updated_at, retry_issues = _read_state(args.state_path) 97 | else: 98 | min_updated_at = None 99 | retry_issues = [] 100 | 101 | if min_updated_at: 102 | logger.info("Starting placeholder: %s", _format_placeholder(min_updated_at)) 103 | if len(retry_issues) > 0: 104 | logger.info("Will retry %s previously failed issues", len(retry_issues)) 105 | else: 106 | logger.warning("Missing placeholder. Will sync issues from all time.") 107 | 108 | new_min_updated_at = datetime.utcnow() 109 | 110 | try: 111 | issue_sync = IssueSync.from_config(config, dry_run=args.dry_run) 112 | failed_issues = issue_sync.perform_sync(min_updated_at, retry_issues=retry_issues) 113 | except Exception: 114 | logger.exception("Fatal error") 115 | return 1 116 | 117 | if len(failed_issues) > 0: 118 | logger.error("%s issues were selected to sync, but failed", len(failed_issues)) 119 | 120 | logger.info("Next placeholder: %s", _format_placeholder(new_min_updated_at)) 121 | if args.state_path and not args.dry_run: 122 | _write_state(args.state_path, new_min_updated_at, failed_issues) 123 | 124 | return 0 125 | 126 | 127 | def _handle_check_permissions(args): 128 | try: 129 | config = load_config(args.config_path) 130 | except Exception: 131 | _print_error("Failed parsing config file:") 132 | traceback.print_exc(file=sys.stderr) 133 | return 1 134 | 135 | try: 136 | errors = check_permissions(config) 137 | except Exception: 138 | logger.exception("Fatal error") 139 | return 1 140 | 141 | if errors: 142 | _print_error("JIRA and/or GitHub permissions must be corrected:") 143 | for error in errors: 144 | _print_error(error) 145 | return 1 146 | else: 147 | print("JIRA and GitHub permissions are sufficient") 148 | return 0 149 | 150 | 151 | def _handle_generate_config(args): 152 | sys.stdout.write(generate_config_template()) 153 | 154 | return 0 155 | 156 | 157 | def _print_error(*args, **kwargs): 158 | print(*args, **kwargs, file=sys.stderr) 159 | 160 | 161 | def _read_state(path): 162 | if os.path.isfile(path): 163 | with open(path, "r") as file: 164 | content = file.read() 165 | 166 | state = json.loads(content) 167 | 168 | if state.get("min_updated_at"): 169 | min_updated_at = datetime.fromisoformat(state["min_updated_at"]).replace(tzinfo=timezone.utc) 170 | else: 171 | min_updated_at = None 172 | 173 | if state.get("retry_issues"): 174 | retry_issues = [(Source[i["source"].upper()], i["issue_id"]) for i in state["retry_issues"]] 175 | else: 176 | retry_issues = [] 177 | 178 | return min_updated_at, retry_issues 179 | else: 180 | logger.warning("State file missing") 181 | return None, [] 182 | 183 | 184 | def _write_state(path, min_updated_at, failed_issues): 185 | state = { 186 | "min_updated_at": _format_placeholder(min_updated_at), 187 | "retry_issues": [{"source": source.name, "issue_id": issue_id} for source, issue_id in failed_issues], 188 | } 189 | 190 | with open(path, "w") as file: 191 | file.write(json.dumps(state)) 192 | 193 | 194 | def _format_placeholder(value): 195 | return value.strftime("%Y-%m-%dT%H:%M:%S.%f") 196 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, timezone 3 | 4 | from jirahub.entities import User, Source, Comment, Issue, Metadata 5 | from jirahub.config import JiraConfig, GithubConfig, JirahubConfig 6 | import jirahub.jira 7 | import jirahub.github 8 | import jirahub.permissions 9 | 10 | from . import constants 11 | from . import mocks 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def setup_environment(monkeypatch): 16 | monkeypatch.setenv("JIRAHUB_JIRA_USERNAME", constants.TEST_JIRA_USERNAME) 17 | monkeypatch.setenv("JIRAHUB_JIRA_PASSWORD", constants.TEST_JIRA_PASSWORD) 18 | monkeypatch.setenv("JIRAHUB_GITHUB_TOKEN", constants.TEST_GITHUB_TOKEN) 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def mock_raw_clients(monkeypatch): 23 | monkeypatch.setattr(jirahub.jira, "JIRA", mocks.MockJIRA) 24 | monkeypatch.setattr(jirahub.permissions, "JIRA", mocks.MockJIRA) 25 | monkeypatch.setattr(jirahub.github, "Github", mocks.MockGithub) 26 | monkeypatch.setattr(jirahub.permissions, "Github", mocks.MockGithub) 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def reset_mocks(): 31 | mocks.reset() 32 | 33 | 34 | @pytest.fixture 35 | def jira_config(): 36 | return JiraConfig( 37 | server=constants.TEST_JIRA_SERVER, 38 | project_key=constants.TEST_JIRA_PROJECT_KEY, 39 | github_issue_url_field_id=12345, 40 | jirahub_metadata_field_id=67890, 41 | ) 42 | 43 | 44 | @pytest.fixture 45 | def github_config(): 46 | return GithubConfig(repository=constants.TEST_GITHUB_REPOSITORY) 47 | 48 | 49 | @pytest.fixture() 50 | def config(jira_config, github_config): 51 | return JirahubConfig(jira=jira_config, github=github_config) 52 | 53 | 54 | @pytest.fixture 55 | def jira_client(): 56 | return mocks.MockClient(source=Source.JIRA) 57 | 58 | 59 | @pytest.fixture 60 | def github_client(): 61 | return mocks.MockClient(source=Source.GITHUB) 62 | 63 | 64 | @pytest.fixture 65 | def create_user(): 66 | next_id = 1 67 | 68 | def _create_user(source, **kwargs): 69 | nonlocal next_id 70 | 71 | fields = { 72 | "source": source, 73 | "username": f"username{next_id}", 74 | "display_name": f"Test {source} User {next_id}", 75 | "raw_user": None, 76 | } 77 | 78 | fields.update(kwargs) 79 | 80 | next_id += 1 81 | 82 | return User(**fields) 83 | 84 | return _create_user 85 | 86 | 87 | @pytest.fixture 88 | def create_bot_user(): 89 | def _create_bot_user(source): 90 | if source == Source.JIRA: 91 | return User( 92 | source=Source.JIRA, 93 | username=constants.TEST_JIRA_USERNAME, 94 | display_name=constants.TEST_JIRA_USER_DISPLAY_NAME, 95 | ) 96 | else: 97 | return User( 98 | source=Source.GITHUB, 99 | username=constants.TEST_GITHUB_USER_LOGIN, 100 | display_name=constants.TEST_GITHUB_USER_NAME, 101 | ) 102 | 103 | return _create_bot_user 104 | 105 | 106 | @pytest.fixture 107 | def create_comment(create_user): 108 | def _create_comment(source, comment_class=Comment, **kwargs): 109 | comment_id = mocks.next_comment_id() 110 | 111 | fields = { 112 | "source": source, 113 | "is_bot": False, 114 | "created_at": datetime.utcnow().replace(tzinfo=timezone.utc), 115 | "updated_at": datetime.utcnow().replace(tzinfo=timezone.utc), 116 | "body": f"Body of comment id {comment_id}.", 117 | "raw_comment": None, 118 | } 119 | 120 | fields.update(kwargs) 121 | 122 | if "user" not in fields: 123 | fields["user"] = create_user(source) 124 | 125 | fields["comment_id"] = mocks.next_comment_id() 126 | 127 | return comment_class(**fields) 128 | 129 | return _create_comment 130 | 131 | 132 | @pytest.fixture 133 | def create_mirror_comment(create_comment, create_bot_user): 134 | def _create_mirror_comment(source, source_comment=None, **kwargs): 135 | kwargs["is_bot"] = True 136 | kwargs["user"] = create_bot_user(source) 137 | 138 | return create_comment(source, **kwargs) 139 | 140 | return _create_mirror_comment 141 | 142 | 143 | @pytest.fixture 144 | def create_issue(create_user, create_comment): 145 | def _create_issue(source, issue_class=Issue, **kwargs): 146 | if source == Source.JIRA: 147 | issue_id = mocks.next_jira_issue_id() 148 | 149 | fields = { 150 | "source": source, 151 | "is_bot": False, 152 | "issue_id": issue_id, 153 | "project": constants.TEST_JIRA_PROJECT_KEY, 154 | "created_at": datetime.utcnow().replace(tzinfo=timezone.utc), 155 | "updated_at": datetime.utcnow().replace(tzinfo=timezone.utc), 156 | "title": f"Title of JIRA issue id {issue_id}", 157 | "body": f"Body of JIRA issue id {issue_id}.", 158 | "labels": {"jiralabel1", "jiralabel2"}, 159 | "is_open": True, 160 | "components": {"jiracomponent1", "jiracomponent2"}, 161 | "raw_issue": None, 162 | "priority": "Major", 163 | "issue_type": "Bug", 164 | "milestones": {"jiramilestone1", "jiramilestone2"}, 165 | } 166 | else: 167 | issue_id = mocks.next_github_issue_id() 168 | 169 | fields = { 170 | "source": source, 171 | "is_bot": False, 172 | "issue_id": issue_id, 173 | "project": constants.TEST_GITHUB_REPOSITORY, 174 | "created_at": datetime.utcnow().replace(tzinfo=timezone.utc), 175 | "updated_at": datetime.utcnow().replace(tzinfo=timezone.utc), 176 | "title": f"Title of GitHub issue id {issue_id}", 177 | "body": f"Body of GitHub issue id {issue_id}.", 178 | "labels": {"githublabel1", "githublabel2"}, 179 | "is_open": True, 180 | "components": {}, 181 | "raw_issue": None, 182 | "priority": None, 183 | "issue_type": None, 184 | "milestones": {"githubmilestone1", "githubmilestone2"}, 185 | } 186 | 187 | fields.update(kwargs) 188 | 189 | if "user" not in fields: 190 | fields["user"] = create_user(source) 191 | 192 | if "comments" not in fields: 193 | fields["comments"] = [] 194 | 195 | return issue_class(**fields) 196 | 197 | return _create_issue 198 | 199 | 200 | @pytest.fixture 201 | def create_mirror_issue(create_issue, create_bot_user): 202 | def _create_mirror_issue(source, source_issue=None, **kwargs): 203 | kwargs["is_bot"] = True 204 | kwargs["user"] = create_bot_user(source) 205 | 206 | if source == Source.JIRA and source_issue: 207 | kwargs["metadata"] = Metadata(github_repository=source_issue.project, github_issue_id=source_issue.issue_id) 208 | 209 | return create_issue(source, **kwargs) 210 | 211 | return _create_mirror_issue 212 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to jirahub's documentation! 2 | ################################### 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | Jirahub provides a configurable tool for synchronization of issues between a 9 | GitHub repository and a JIRA project. With it, you can use GitHub for coding 10 | and ticket tracking while using JIRA for ticket tracking and project management. 11 | 12 | Download and install 13 | ==================== 14 | 15 | To download and install: 16 | 17 | .. code-block:: bash 18 | 19 | $ pip install jirahub 20 | 21 | The package's sole requirements are `PyGithub `_ and 22 | `JIRA `_. Both of these dependencies are installable via pip. 23 | 24 | JIRA configuration 25 | ================== 26 | 27 | jirahub stores state on the JIRA issue in two custom fields, which you (or your JIRA administrator) 28 | will need to create. The first field stores the URL of a linked GitHub issue, and should be 29 | type "URL Field". The second stores a JSON object containing general jirahub metadata, and should 30 | be type "Text field (multi-line)". 31 | 32 | jirahub configuration 33 | ===================== 34 | 35 | Jirahub configuration is divided between environment variables (JIRA and GitHub credentials) 36 | and one or more .py files (all other parameters). 37 | 38 | Environment variables 39 | --------------------- 40 | 41 | Your JIRA and GitHub credentials are provided to jirahub via environment variables: 42 | 43 | ===================== =================================================================================================================================== 44 | Variable name Description 45 | ===================== =================================================================================================================================== 46 | JIRAHUB_JIRA_USERNAME JIRA username of your jirahub bot 47 | JIRAHUB_JIRA_PASSWORD JIRA password of your jirahub bot 48 | JIRAHUB_GITHUB_TOKEN GitHub `API token `_ of your jirahub bot 49 | ===================== =================================================================================================================================== 50 | 51 | Configuration file 52 | ------------------ 53 | 54 | The remaining parameters are specified in a Python configuration file. There are few required 55 | parameters, but jirahub takes no actions by default, so users must explicitly enable features that 56 | they wish to use. The `generate-config`_ command can be used to create an initial configuration file. 57 | The file is executed with the ``c`` variable bound to an instance of ``jirahub.config.JirahubConfig``, 58 | which has two attributes, ``jira`` and ``github``. 59 | 60 | jira 61 | ```` 62 | 63 | These are parameters particular to JIRA. The ``server`` and ``project_key`` attributes are required. 64 | 65 | .. list-table:: 66 | :header-rows: 1 67 | :widths: 30 70 68 | 69 | * - Name 70 | - Description 71 | * - c.jira.server 72 | - The URL of your JIRA server (e.g., https://my-jira.example.com) 73 | * - c.jira.project_key 74 | - The project key of the JIRA project that will be synced 75 | * - c.jira.github_issue_url_field_id 76 | - The integer ID of a JIRA custom field in which jirahub will write the URL of the 77 | linked GitHub issue. 78 | * - c.jira.jirahub_metadata_field_id 79 | - The integer ID of a JIRA custom field in which jirahub will write metadata such as 80 | the ids of linked comments. 81 | * - c.jira.closed_statuses 82 | - List of JIRA statuses that will be considered closed. All others will be treated as 83 | open, for the purposes of syncing GitHub open/closed status and filtering issues. 84 | These values are case-insensitive. 85 | * - c.jira.close_status 86 | - JIRA status set on an issue when closed by the bot 87 | * - c.jira.reopen_status 88 | - JIRA status set on an issue when re-opened by the bot 89 | * - c.jira.open_status 90 | - JIRA status set on a newly created issue. Set to None to use your project's 91 | default for new issues. 92 | * - c.jira.max_retries 93 | - Maximum number of retries on request failure 94 | * - c.jira.notify_watchers 95 | - Set to ``True`` if watchers should be notified when an issue is updated by the bot 96 | * - c.jira.sync_comments 97 | - Set to ``True`` if JIRA comments should be created from GitHub comments 98 | * - c.jira.sync_status 99 | - Set to ``True`` if the JIRA issue status should be set based on the GitHub open/closed status 100 | * - c.jira.sync_labels 101 | - Set to ``True`` if the JIRA issue's labels should match GitHub's labels 102 | * - c.jira.sync_milestones 103 | - Set to ``True`` if the JIRA issue's fixVersions field should match GitHub's milestone 104 | * - c.jira.create_tracking_comment 105 | - Set to ``True`` to create a comment on JIRA issues that links back to GitHub. 106 | * - c.jira.redact_patterns 107 | - List of ``re.Pattern`` whose matches will be redacted from issue titles, 108 | issue bodies, and comment bodies copied over from GitHub 109 | * - c.jira.issue_title_formatter 110 | - Callable that transforms the GitHub issue title before creating/updating it 111 | in JIRA. See `Custom formatters`_ for further detail. 112 | * - c.jira.issue_body_formatter 113 | - Callable that transforms the GitHub issue body before creating/updating it 114 | in JIRA. See `Custom formatters`_ for further detail. 115 | * - c.jira.comment_body_formatter 116 | - Callable that transforms the GitHub comment body before creating/updating it 117 | in JIRA. See `Custom formatters`_ for further detail. 118 | * - c.jira.issue_filter 119 | - Callable that selects GitHub issues that will be created in JIRA. See 120 | `Issue filters`_ for further detail. 121 | * - c.jira.before_issue_create 122 | - List of callables that transform the fields used to create a new JIRA issue. 123 | This can (for example) be used to override jirahub's behavior, or set values 124 | for arbitrary custom fields. See `Issue hooks`_ for further detail. 125 | 126 | github 127 | `````` 128 | 129 | These are parameters particular to GitHub. The ``repository`` parameter is required. 130 | 131 | .. list-table:: 132 | :header-rows: 1 133 | :widths: 30 70 134 | 135 | * - Name 136 | - Description 137 | * - c.github.repository 138 | - GitHub repository name with organization, e.g., spacetelescope/jwst 139 | * - c.github.max_retries 140 | - Maximum number of retries on request failure 141 | * - c.github.sync_comments 142 | - Set to ``True`` if GitHub comments should be created from JIRA comments 143 | * - c.github.sync_status 144 | - Set to ``True`` if the GitHub issue status should be set based on the JIRA open/closed status 145 | * - c.github.sync_labels 146 | - Set to ``True`` if the GitHub issue's labels should match JIRA's labels 147 | * - c.github.sync_milestones 148 | - Set to ``True`` if the GitHub issue's fixVersions field should match JIRA's milestone 149 | * - c.jira.create_tracking_comment 150 | - Set to ``True`` to create a comment on GitHub issues that links back to JIRA. 151 | * - c.github.redact_patterns 152 | - List of ``re.Pattern`` whose matches will be redacted from issue titles, 153 | issue bodies, and comment bodies copied over from JIRA 154 | * - c.github.issue_title_formatter 155 | - Callable that transforms the JIRA issue title before creating/updating it 156 | in GitHub. See `Custom formatters`_ for further detail. 157 | * - c.github.issue_body_formatter 158 | - Callable that transforms the JIRA issue body before creating/updating it 159 | in GitHub. See `Custom formatters`_ for further detail. 160 | * - c.github.comment_body_formatter 161 | - Callable that transforms the JIRA comment body before creating/updating it 162 | in GitHub. See `Custom formatters`_ for further detail. 163 | * - c.github.issue_filter 164 | - Callable that selects JIRA issues that will be created in GitHub. See 165 | `Issue filters`_ for further detail. 166 | * - c.github.before_issue_create 167 | - List of callables that transform the fields used to create a new GitHub issue. 168 | This can (for example) be used to override jirahub's behavior, or set values 169 | for fields (such as ``assignee``) that aren't otherwise managed by jirahub. 170 | See `Issue hooks`_ for further detail. 171 | 172 | general 173 | ``````` 174 | These are parameters shared by GitHub and JIRA. 175 | 176 | .. list-table:: 177 | :header-rows: 1 178 | :widths: 30 70 179 | 180 | * - Name 181 | - Description 182 | * - c.before_issue_update 183 | - List of callables that transform the fields used to update an issue. 184 | This can (for example) be used to override jirahub's behavior, or set values 185 | for arbitrary custom fields. See `Issue hooks`_ for further detail. 186 | 187 | 188 | 189 | Multiple configuration files 190 | ```````````````````````````` 191 | 192 | To facilitate re-use of common parameters, jirahub commands will accept multiple 193 | configuration file paths. 194 | 195 | Command-line interface 196 | ====================== 197 | 198 | Jirahub is controlled with the ``jirahub`` command. There are three subcommands: ``generate-config``, 199 | ``check-permissions``, and ``sync``. 200 | 201 | generate-config 202 | --------------- 203 | 204 | The ``generate-config`` command will print a template jirahub configuration file to stdout: 205 | 206 | .. code-block:: bash 207 | 208 | $ jirahub generate-config > my-jirahub-config.py 209 | 210 | check-permissions 211 | ----------------- 212 | 213 | Once you're satisfied with your configuration file, you can submit it to the ``check-permissions`` 214 | command for verification. Jirahub will attempt to connect to your JIRA server and GitHub 215 | repository and report any failures. It will also list any missing permissions from JIRA or GitHub 216 | that are required for the features selected in the configuration file. A successful check looks 217 | like this: 218 | 219 | .. code-block:: bash 220 | 221 | $ jirahub check-permissions my-jirahub-config.py 222 | JIRA and GitHub permissions are sufficient 223 | 224 | And an unsuccessful check: 225 | 226 | .. code-block:: bash 227 | 228 | $ jirahub check-permissions my-jirahub-config.py 229 | JIRA and/or GitHub permissions must be corrected: 230 | sync_comments is enabled, but JIRA user has not been granted the DELETE_OWN_COMMENTS permission. 231 | sync_status is enabled, but JIRA user has not been granted the CLOSE_ISSUES permission. 232 | GitHub rejected credentials. Check JIRAHUB_GITHUB_TOKEN and try again. 233 | 234 | sync 235 | ---- 236 | 237 | The ``sync`` command does the work of syncing issues and comments. At minimum, you must 238 | specify a configuration file. Additional options include: 239 | 240 | * **--min-updated-at**: Restrict jirahub's activity to issues updated after this timestamp. The timestamp 241 | format is ISO-8601 in UTC with no timezone suffix (e.g., 1983-11-20T11:00:00). 242 | 243 | * **--state-path**: Path to a JSON file containing the same timestamp described above, as well as 244 | a list of issues that failed. The file will be updated after each run. 245 | 246 | * **--dry-run**: Query issues and report changes to the (verbose) log, but do not change any data. 247 | 248 | * **--verbose**: Enable verbose logging 249 | 250 | Jirahub sync as a cron job 251 | `````````````````````````` 252 | 253 | Users will likely want to run ``jirahub sync`` in a cron job, so that it can regularly poll JIRA/GitHub 254 | for changes. We recommend use of the `lockrun `_ tool to 255 | avoid overlap between jirahub processes. Your cron line might look something like this:: 256 | 257 | */5 * * * * lockrun --lockfile=/path/to/jirahub.lockrun -- jirahub sync /path/to/my-jirahub-config.py --state-path /path/to/jirahub-state.json >> /path/to/jirahub.log 2>&1 258 | 259 | Custom formatters 260 | ================= 261 | 262 | The ``issue_title_formatter``, ``issue_body_formatter``, and ``comment_body_formatter`` parameters allow you to customize 263 | how the issue and comment text fields are written to the linked issue. The issue formatters are callables that receive 264 | two arguments, the original ``jirahub.entities.Issue`` that is being synced, and the title/body string. The title/body 265 | has already been modified by jirahub; it has been redacted, if that feature is enabled, and the formatting has been 266 | transformed to suit the target service. The following formatter adds a "JIRAHUB: " prefix to JIRA issue titles: 267 | 268 | .. code-block:: python 269 | 270 | def custom_formatter(issue, title): 271 | return "JIRAHUB: " + title 272 | 273 | c.jira.issue_title_formatter = custom_formatter 274 | 275 | The original issue title/body (without jirahub's modifications) is available from the issue object: 276 | 277 | .. code-block:: python 278 | 279 | def custom_formatter(issue, body): 280 | return "This is the original body: " + issue.body 281 | 282 | c.jira.issue_body_formatter = custom_formatter 283 | 284 | If you need access to a custom field that isn't recognized by jirahub, that is available via the ``raw_issue``, 285 | which contains the ``jira.resources.Issue`` or ``github.Issue`` that was used to construct the jirahub Issue. 286 | 287 | .. code-block:: python 288 | 289 | def custom_formatter(issue, body): 290 | return "This is some custom field value: " + issue.raw_issue.body 291 | 292 | c.jira.issue_body_formatter = custom_formatter 293 | 294 | The ``comment_body_formatter`` is similar, except that it receives three arguments, the original ``jirahub.entities.Issue``, 295 | the ``jirahub.entities.Comment``, and the comment body. 296 | 297 | .. code-block:: python 298 | 299 | def custom_formatter(issue, comment, body): 300 | return "Check out this great comment from GitHub: " + body 301 | 302 | c.jira.comment_body_formatter = custom_formatter 303 | 304 | The unmodified comment body is available from ``comment.body``, and the JIRA/GitHub comment object 305 | from ``comment.raw_comment``. 306 | 307 | Issue filters 308 | ============= 309 | 310 | The ``issue_filter`` parameter allows you to select issues that will be created in the target 311 | service. The filter is a callable that receives a single argument, the original 312 | ``jirahub.entities.Issue`` that is a candidate for sync, and returns True to create it, or False 313 | to ignore it. For example, this filter only syncs issues with a certain label: 314 | 315 | .. code-block:: python 316 | 317 | def issue_filter(issue): 318 | return "sync-me" in issue.labels 319 | 320 | c.jira.issue_filter = issue_filter 321 | 322 | This feature can be used to sync issues based on "commands" issued by commenters: 323 | 324 | .. code-block:: python 325 | 326 | ADMINISTRATOR_USERNAMES = { 327 | "linda", 328 | "frank" 329 | } 330 | 331 | def issue_filter(issue): 332 | return any(c for c in issue.comments if c.user.username in ADMINISTRATOR_USERNAMES and "SYNC ME PLEASE" in c.body) 333 | 334 | c.jira.issue_filter = issue_filter 335 | 336 | Issue hooks 337 | =========== 338 | 339 | The ``before_issue_create`` and ``before_issue_update`` hooks allow you to transform the fields sent 340 | to JIRA/GitHub when an issue is created. They can override jirahub's behavior, or set custom fields 341 | that aren't otherwise managed by jirahub. 342 | 343 | The create hooks are callables that receive 2 required arguments: the original ``jirahub.entities.Issue``, 344 | and a ``dict`` of fields that will be used to create the issue. The 3rd optional argument, if present, 345 | will receive the the ``jirahub.IssueSync`` instance. Each callable must return a ``dict`` containing 346 | the transformed fields. 347 | 348 | The update hooks are callables that receive 4 required arguments: the updated ``jirahub.entities.Issue``, 349 | a ``dict`` of fields that will be used to modify that issue, the corresponding linked ``jirahub.entities.Issue``, 350 | and another ``dict`` of fields. The 5th optional argument, if present, will receive the ``jirahub.IssueSync`` 351 | instance. Each callable must return two ``dict`` instances containing the transformed fields. 352 | 353 | For example, this create hook sets a custom JIRA field: 354 | 355 | .. code-block:: python 356 | 357 | def hook(issue, fields): 358 | fields["custom_jira_field"] = "some custom value" 359 | return fields 360 | 361 | c.jira.before_issue_create.append(hook) 362 | 363 | Manually linking issues 364 | ======================= 365 | 366 | It is possible to link existing GitHub and JIRA issues by hand by setting the GitHub issue URL field 367 | in JIRA. jirahub will begin syncing the two issues on next run. Take care that you don't link two 368 | JIRA issues to the same GitHub issue, that way lies peril (undefined behavior). 369 | -------------------------------------------------------------------------------- /jirahub/github.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import os 4 | from datetime import timezone 5 | 6 | from github import Github, UnknownObjectException 7 | 8 | from .entities import Issue, Comment, User, Source 9 | from .utils import isolate_regions 10 | 11 | 12 | __all__ = ["Client", "Formatter", "get_token"] 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def get_token(): 19 | return os.environ.get("JIRAHUB_GITHUB_TOKEN") 20 | 21 | 22 | def _parse_datetime(value): 23 | # GitHub datetimes are datetime objects in UTC, they just don't have tzinfo set. 24 | return value.replace(tzinfo=timezone.utc) 25 | 26 | 27 | class _IssueMapper: 28 | """ 29 | This class is responsible for mapping the fields of the GitHub client's resource objects 30 | to our own in jirahub.entities. 31 | """ 32 | 33 | def __init__(self, bot_username, raw_milestones): 34 | self._bot_username = bot_username 35 | self._raw_milestones_by_name = {m.title: m for m in raw_milestones} 36 | 37 | def get_user(self, raw_user): 38 | if not raw_user.name: 39 | display_name = raw_user.login 40 | else: 41 | display_name = raw_user.name 42 | 43 | return User(source=Source.GITHUB, username=raw_user.login, display_name=display_name, raw_user=raw_user) 44 | 45 | def get_comment(self, raw_comment): 46 | user = self.get_user(raw_comment.user) 47 | is_bot = user.username == self._bot_username 48 | 49 | body = raw_comment.body 50 | if body is None: 51 | body = "" 52 | 53 | comment_id = raw_comment.id 54 | created_at = _parse_datetime(raw_comment.created_at) 55 | updated_at = _parse_datetime(raw_comment.updated_at) 56 | 57 | return Comment( 58 | source=Source.GITHUB, 59 | is_bot=is_bot, 60 | comment_id=comment_id, 61 | created_at=created_at, 62 | updated_at=updated_at, 63 | user=user, 64 | body=body, 65 | raw_comment=raw_comment, 66 | ) 67 | 68 | def get_raw_comment_fields(self, fields, comment=None): 69 | raw_fields = {} 70 | 71 | if "body" in fields: 72 | if fields["body"]: 73 | body = fields["body"] 74 | else: 75 | body = "" 76 | raw_fields["body"] = body 77 | 78 | return raw_fields 79 | 80 | def get_issue(self, raw_issue, raw_comments): 81 | user = self.get_user(raw_issue.user) 82 | is_bot = user.username == self._bot_username 83 | 84 | comments = [self.get_comment(c) for c in raw_comments] 85 | 86 | body = raw_issue.body 87 | if body is None: 88 | body = "" 89 | 90 | issue_id = raw_issue.number 91 | project = raw_issue.repository.full_name 92 | created_at = _parse_datetime(raw_issue.created_at) 93 | updated_at = _parse_datetime(raw_issue.updated_at) 94 | 95 | title = raw_issue.title 96 | labels = {l.name for l in raw_issue.labels} 97 | 98 | if raw_issue.milestone: 99 | milestones = {raw_issue.milestone.title} 100 | else: 101 | milestones = set() 102 | 103 | is_open = raw_issue.state == "open" 104 | 105 | return Issue( 106 | source=Source.GITHUB, 107 | is_bot=is_bot, 108 | issue_id=issue_id, 109 | project=project, 110 | created_at=created_at, 111 | updated_at=updated_at, 112 | user=user, 113 | title=title, 114 | body=body, 115 | labels=labels, 116 | is_open=is_open, 117 | milestones=milestones, 118 | comments=comments, 119 | raw_issue=raw_issue, 120 | ) 121 | 122 | def get_raw_issue_fields(self, fields, issue=None): 123 | fields = fields.copy() 124 | raw_fields = {} 125 | 126 | if "title" in fields: 127 | raw_fields["title"] = fields.pop("title") 128 | 129 | if "body" in fields: 130 | if fields["body"]: 131 | body = fields["body"] 132 | else: 133 | body = "" 134 | raw_fields["body"] = body 135 | fields.pop("body") 136 | 137 | if "milestones" in fields: 138 | raw_milestones = [] 139 | if fields["milestones"]: 140 | for milestone in fields["milestones"]: 141 | if milestone in self._raw_milestones_by_name: 142 | raw_milestones.append(self._raw_milestones_by_name[milestone]) 143 | else: 144 | if issue: 145 | issue_str = issue 146 | else: 147 | issue_str = "New issue" 148 | logger.warning("%s has milestone %s, but it is not available on GitHub", issue_str, milestone) 149 | 150 | if raw_milestones: 151 | if len(raw_milestones) > 1: 152 | if issue: 153 | issue_str = issue 154 | else: 155 | issue_str = "New issue" 156 | logger.warning( 157 | "%s has multiple milestones (%s), but GitHub only supports one; choosing %s", 158 | issue_str, 159 | ", ".join([m.title for m in raw_milestones]), 160 | raw_milestones[0].title, 161 | ) 162 | raw_fields["milestone"] = raw_milestones[0] 163 | elif issue: 164 | raw_fields["milestone"] = None 165 | fields.pop("milestones") 166 | 167 | if "labels" in fields: 168 | if fields["labels"]: 169 | raw_fields["labels"] = list(fields["labels"]) 170 | else: 171 | raw_fields["labels"] = [] 172 | fields.pop("labels") 173 | 174 | if "is_open" in fields: 175 | if fields["is_open"]: 176 | raw_fields["state"] = "open" 177 | else: 178 | raw_fields["state"] = "closed" 179 | fields.pop("is_open") 180 | 181 | raw_fields.update(fields) 182 | return raw_fields 183 | 184 | 185 | class Client: 186 | @classmethod 187 | def from_config(cls, config): 188 | github = Github(get_token(), retry=config.github.max_retries) 189 | 190 | return cls(config, github) 191 | 192 | def __init__(self, config, github): 193 | self._config = config 194 | self._github = github 195 | self._repo = github.get_repo(config.github.repository) 196 | self._mapper = _IssueMapper(github.get_user().login, self._repo.get_milestones()) 197 | 198 | def get_user(self, username): 199 | return self._mapper.get_user(self._github.get_user(username)) 200 | 201 | def find_issues(self, min_updated_at=None): 202 | if min_updated_at: 203 | assert min_updated_at.tzinfo is not None 204 | 205 | if min_updated_at: 206 | raw_issues = self._repo.get_issues( 207 | sort="updated", direction="asc", since=min_updated_at.astimezone(timezone.utc), state="all" 208 | ) 209 | else: 210 | raw_issues = self._repo.get_issues(sort="updated", direction="asc", state="all") 211 | 212 | # Already paginated by GitHub's client: 213 | for raw_issue in raw_issues: 214 | # The GitHub API treats pull requests as issues (but not the other way around): 215 | if not raw_issue.pull_request: 216 | yield self._mapper.get_issue(raw_issue, raw_issue.get_comments()) 217 | 218 | def find_other_issue(self, jira_issue): 219 | assert jira_issue.source == Source.JIRA 220 | 221 | if jira_issue.metadata.github_repository and jira_issue.metadata.github_issue_id: 222 | assert jira_issue.metadata.github_repository == self._config.github.repository 223 | raw_issue = self._repo.get_issue(jira_issue.metadata.github_issue_id) 224 | return self._mapper.get_issue(raw_issue, raw_issue.get_comments()) 225 | else: 226 | return None 227 | 228 | def get_issue(self, issue_id): 229 | raw_issue = self._repo.get_issue(issue_id) 230 | return self._mapper.get_issue(raw_issue, raw_issue.get_comments()) 231 | 232 | def create_issue(self, fields): 233 | raw_fields = self._mapper.get_raw_issue_fields(fields) 234 | raw_issue = self._repo.create_issue(**raw_fields) 235 | new_issue = self._mapper.get_issue(raw_issue, []) 236 | 237 | logger.info("Created %s", new_issue) 238 | 239 | return new_issue 240 | 241 | def update_issue(self, issue, fields): 242 | assert issue.source == Source.GITHUB 243 | 244 | if ("title" in fields or "body" in fields) and not issue.is_bot: 245 | raise ValueError("Cannot update title or body of issue owned by another user") 246 | 247 | raw_fields = self._mapper.get_raw_issue_fields(fields, issue=issue) 248 | issue.raw_issue.edit(**raw_fields) 249 | updated_issue = self._mapper.get_issue(issue.raw_issue, issue.raw_issue.get_comments()) 250 | 251 | logger.info("Updated %s", updated_issue) 252 | 253 | return updated_issue 254 | 255 | def create_comment(self, issue, fields): 256 | assert issue.source == Source.GITHUB 257 | 258 | raw_fields = self._mapper.get_raw_comment_fields(fields) 259 | raw_comment = issue.raw_issue.create_comment(**raw_fields) 260 | new_comment = self._mapper.get_comment(raw_comment) 261 | 262 | logger.info("Created %s on %s", new_comment, issue) 263 | 264 | return new_comment 265 | 266 | def update_comment(self, comment, fields): 267 | assert comment.source == Source.GITHUB 268 | 269 | if not comment.is_bot: 270 | raise ValueError("Cannot update comment owned by another user") 271 | 272 | raw_fields = self._mapper.get_raw_comment_fields(fields, comment=comment) 273 | comment.raw_comment.edit(**raw_fields) 274 | 275 | logger.info("Updated %s", comment) 276 | 277 | def delete_comment(self, comment): 278 | assert comment.source == Source.GITHUB 279 | 280 | if not comment.is_bot: 281 | raise ValueError("Cannot delete comment owned by another user") 282 | 283 | comment.raw_comment.delete() 284 | 285 | logger.info("Deleted %s", comment) 286 | 287 | def is_issue(self, issue_id): 288 | try: 289 | issue = self._repo.get_issue(issue_id) 290 | except UnknownObjectException: 291 | return False 292 | else: 293 | # The GitHub API treats pull requests as issues (but not the other way around): 294 | if issue.pull_request: 295 | return False 296 | else: 297 | return True 298 | 299 | def is_pull_request(self, pr_id): 300 | try: 301 | self._repo.get_pull(pr_id) 302 | except UnknownObjectException: 303 | return False 304 | else: 305 | return True 306 | 307 | 308 | class Formatter: 309 | ISSUE_RE = re.compile(r"^https://github.com/([^/]+/[^/]+)/issues/([0-9]+)$") 310 | PR_RE = re.compile(r"^https://github.com/([^/]+/[^/]+)/pull/([0-9]+)$") 311 | USER_PROFILE_RE = re.compile(r"^https://github.com/([^/]+)$") 312 | H1_RE = re.compile(r"\bh1\. ") 313 | H2_RE = re.compile(r"\bh2\. ") 314 | H3_RE = re.compile(r"\bh3\. ") 315 | H4_RE = re.compile(r"\bh4\. ") 316 | H5_RE = re.compile(r"\bh5\. ") 317 | H6_RE = re.compile(r"\bh6\. ") 318 | CODE_OPEN_RE = re.compile(r"\{code(:(.*?))?\}") 319 | CODE_CLOSE_RE = re.compile(r"\{code\}") 320 | NOFORMAT_RE = re.compile(r"\{noformat\}") 321 | QUOTE_RE = re.compile(r"\{quote\}") 322 | COLOR_RE = re.compile(r"\{color.*?\}") 323 | HASH_NUMBER_RE = re.compile(r"#([0-9]+)") 324 | BOLD_RE = re.compile(r"(^|\W)\*(\w(.*?\w)?)\*($|\W)") 325 | ITALIC_RE = re.compile(r"(^|\W)_(\w(.*?\w)?)_($|\W)") 326 | MONOSPACED_RE = re.compile(r"\{\{(.*?)\}\}") 327 | STRIKETHROUGH_RE = re.compile(r"(^|\W)-(\w(.*?\w)?)-($|\W)") 328 | INSERTED_RE = re.compile(r"(^|\W)\+(\w(.*?\w)?)\+($|\W)") 329 | SUPERSCRIPT_RE = re.compile(r"(^|\W)\^(\w(.*?\w)?)\^($|\W)") 330 | SUBSCRIPT_RE = re.compile(r"(^|\W)~(\w(.*?\w)?)~($|\W)") 331 | URL_WITH_TEXT_RE = re.compile(r"\[(.*?)\|(http.*?)\]") 332 | URL_RE = re.compile(r"(\s|^)\[?(http.*?)\]?(\s|$)") 333 | USER_MENTION_RE = re.compile(r"\[~(.+?)\]") 334 | GITHUB_USER_MENTION_RE = re.compile(r"(^|\s)@([0-9a-zA-Z-]+)\b") 335 | 336 | def __init__(self, config, url_helper, jira_client): 337 | self._config = config 338 | self._url_helper = url_helper 339 | self._jira_client = jira_client 340 | 341 | def format_link(self, url, link_text=None): 342 | if link_text: 343 | return f"[{link_text}]({url})" 344 | else: 345 | match = Formatter.ISSUE_RE.match(url) 346 | if match: 347 | repository = match.group(1) 348 | issue_id = int(match.group(2)) 349 | 350 | if repository == self._config.github.repository: 351 | return f"#{issue_id}" 352 | else: 353 | return f"{repository}#{issue_id}" 354 | 355 | match = Formatter.PR_RE.match(url) 356 | if match: 357 | repository = match.group(1) 358 | pr_id = int(match.group(2)) 359 | 360 | if repository == self._config.github.repository: 361 | return f"#{pr_id}" 362 | else: 363 | return f"{repository}#{pr_id}" 364 | 365 | match = Formatter.USER_PROFILE_RE.match(url) 366 | if match: 367 | username = match.group(1) 368 | return f"@{username}" 369 | 370 | return f"<{url}>" 371 | 372 | def format_body(self, body): 373 | regions = [(body, True)] 374 | 375 | regions = isolate_regions(regions, Formatter.CODE_OPEN_RE, Formatter.CODE_CLOSE_RE, self._handle_code_content) 376 | 377 | regions = isolate_regions(regions, Formatter.NOFORMAT_RE, Formatter.NOFORMAT_RE, self._handle_noformat_content) 378 | 379 | regions = isolate_regions(regions, Formatter.QUOTE_RE, Formatter.QUOTE_RE, self._handle_quoted_content) 380 | 381 | result = "" 382 | for content, formatted in regions: 383 | if formatted: 384 | content = self._format_content(content) 385 | result = result + content 386 | 387 | return result 388 | 389 | def _handle_noformat_content(self, content, open_match): 390 | if len(content) > 0: 391 | return (f"```{content}```", False) 392 | else: 393 | return ("", False) 394 | 395 | def _handle_code_content(self, content, open_match): 396 | if open_match.group(2): 397 | language = open_match.group(2) 398 | else: 399 | language = "" 400 | 401 | if len(content) > 0: 402 | return (f"```{language}{content}```", False) 403 | else: 404 | return ("", False) 405 | 406 | def _handle_quoted_content(self, content, open_match): 407 | if len(content) > 0: 408 | lines = content.split("\n") 409 | if not lines[0].strip(): 410 | lines = lines[1:] 411 | if not lines[-1].strip(): 412 | lines = lines[:-1] 413 | content = "\n" + "\n".join("> " + l for l in lines) + "\n" 414 | return (content, True) 415 | else: 416 | return ("", False) 417 | 418 | def _format_user_mention(self, match): 419 | username = match.group(1) 420 | try: 421 | user = self._jira_client.get_user(username) 422 | except Exception: 423 | logger.warning("Missing JIRA user with username %s", username) 424 | user = None 425 | 426 | if user: 427 | link_text = user.display_name 428 | else: 429 | link_text = username 430 | 431 | url = self._url_helper.get_user_profile_url(source=Source.JIRA, username=username) 432 | 433 | return self.format_link(url, link_text) 434 | 435 | def _format_github_user_mention(self, match): 436 | # Insert an invisible character between the @ and the username 437 | # to prevent GitHub from mentioning a user. U+2063 is the 438 | # "invisible separator" code point. 439 | return match.group(1) + "@\u2063" + match.group(2) 440 | 441 | def _format_content(self, content): 442 | # Perform this transformation early since we don't want it to end up escaping 443 | # intentional user mentions: 444 | content = Formatter.GITHUB_USER_MENTION_RE.sub(self._format_github_user_mention, content) 445 | 446 | content = Formatter.HASH_NUMBER_RE.sub(lambda match: f"#⁠{match.group(1)}", content) 447 | content = Formatter.H1_RE.sub("# ", content) 448 | content = Formatter.H2_RE.sub("## ", content) 449 | content = Formatter.H3_RE.sub("### ", content) 450 | content = Formatter.H4_RE.sub("#### ", content) 451 | content = Formatter.H5_RE.sub("##### ", content) 452 | content = Formatter.H6_RE.sub("###### ", content) 453 | content = Formatter.COLOR_RE.sub("", content) 454 | content = Formatter.BOLD_RE.sub( 455 | lambda match: match.group(1) + f"**{match.group(2)}**" + match.group(4), content 456 | ) 457 | content = Formatter.ITALIC_RE.sub( 458 | lambda match: match.group(1) + f"*{match.group(2)}*" + match.group(4), content 459 | ) 460 | content = Formatter.SUBSCRIPT_RE.sub( 461 | lambda match: match.group(1) + f"{match.group(2)}" + match.group(4), content 462 | ) 463 | content = Formatter.MONOSPACED_RE.sub(lambda match: f"`{match.group(1)}`", content) 464 | content = Formatter.STRIKETHROUGH_RE.sub( 465 | lambda match: match.group(1) + f"~~{match.group(2)}~~" + match.group(4), content 466 | ) 467 | content = Formatter.INSERTED_RE.sub( 468 | lambda match: match.group(1) + f"{match.group(2)}" + match.group(4), content 469 | ) 470 | content = Formatter.SUPERSCRIPT_RE.sub( 471 | lambda match: match.group(1) + f"{match.group(2)}" + match.group(4), content 472 | ) 473 | content = Formatter.URL_WITH_TEXT_RE.sub( 474 | lambda match: self.format_link(match.group(2), match.group(1)), content 475 | ) 476 | content = Formatter.URL_RE.sub( 477 | lambda match: match.group(1) + self.format_link(match.group(2)) + match.group(3), content 478 | ) 479 | content = Formatter.USER_MENTION_RE.sub(self._format_user_mention, content) 480 | 481 | return content 482 | -------------------------------------------------------------------------------- /jirahub/jira.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime, timezone 4 | from functools import lru_cache 5 | import logging 6 | import os 7 | import dataclasses 8 | import urllib 9 | 10 | from jira import JIRA 11 | 12 | from .entities import Issue, Comment, User, Source, Metadata, CommentMetadata 13 | from . import utils 14 | 15 | 16 | __all__ = ["Client", "Formatter", "get_username", "get_password"] 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def get_username(): 23 | return os.environ.get("JIRAHUB_JIRA_USERNAME") 24 | 25 | 26 | def get_password(): 27 | return os.environ.get("JIRAHUB_JIRA_PASSWORD") 28 | 29 | 30 | _JIRA_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" 31 | 32 | 33 | def _parse_datetime(value): 34 | return datetime.strptime(value, _JIRA_DATETIME_FORMAT).astimezone(timezone.utc) 35 | 36 | 37 | def _client_field_name(field_id): 38 | return f"customfield_{field_id}" 39 | 40 | 41 | def _jql_field_name(field_id): 42 | return f"cf[{field_id}]" 43 | 44 | 45 | class _IssueMapper: 46 | """ 47 | This class is responsible for mapping the fields of the JIRA client's resource objects 48 | to our own in jirahub.entities. 49 | """ 50 | 51 | def __init__(self, config, bot_username): 52 | self._config = config 53 | self._bot_username = bot_username 54 | 55 | def get_user(self, raw_user): 56 | if not raw_user.displayName: 57 | display_name = raw_user.name 58 | else: 59 | display_name = raw_user.displayName 60 | 61 | return User(source=Source.JIRA, username=raw_user.name, display_name=display_name, raw_user=raw_user) 62 | 63 | def get_comment(self, raw_comment): 64 | user = self.get_user(raw_comment.author) 65 | is_bot = user.username == self._bot_username 66 | 67 | body = raw_comment.body 68 | if body is None: 69 | body = "" 70 | 71 | comment_id = raw_comment.id 72 | 73 | created_at = _parse_datetime(raw_comment.created) 74 | updated_at = _parse_datetime(raw_comment.updated) 75 | 76 | return Comment( 77 | source=Source.JIRA, 78 | is_bot=is_bot, 79 | comment_id=comment_id, 80 | created_at=created_at, 81 | updated_at=updated_at, 82 | user=user, 83 | body=body, 84 | raw_comment=raw_comment, 85 | ) 86 | 87 | def get_raw_comment_fields(self, fields, comment=None): 88 | raw_fields = {} 89 | 90 | if "body" in fields: 91 | if fields["body"]: 92 | body = fields["body"] 93 | else: 94 | body = "" 95 | raw_fields["body"] = body 96 | 97 | return raw_fields 98 | 99 | def get_issue(self, raw_issue, raw_comments): 100 | user = self.get_user(raw_issue.fields.creator) 101 | is_bot = user.username == self._bot_username 102 | 103 | comments = [self.get_comment(c) for c in raw_comments] 104 | 105 | body = raw_issue.fields.description 106 | if body is None: 107 | body = "" 108 | 109 | issue_id = raw_issue.key 110 | project = raw_issue.fields.project.key 111 | created_at = _parse_datetime(raw_issue.fields.created) 112 | updated_at = _parse_datetime(raw_issue.fields.updated) 113 | title = raw_issue.fields.summary 114 | labels = set([urllib.parse.unquote(l) for l in raw_issue.fields.labels]) 115 | milestones = {v.name for v in raw_issue.fields.fixVersions} 116 | components = {c.name for c in raw_issue.fields.components} 117 | 118 | if raw_issue.fields.priority: 119 | priority = raw_issue.fields.priority.name 120 | else: 121 | priority = None 122 | 123 | if raw_issue.fields.issuetype: 124 | issue_type = raw_issue.fields.issuetype.name 125 | else: 126 | issue_type = None 127 | 128 | is_open = raw_issue.fields.status.name.lower() not in [s.lower() for s in self._config.jira.closed_statuses] 129 | 130 | metadata = self._get_metadata(raw_issue) 131 | 132 | return Issue( 133 | source=Source.JIRA, 134 | is_bot=is_bot, 135 | issue_id=issue_id, 136 | project=project, 137 | created_at=created_at, 138 | updated_at=updated_at, 139 | user=user, 140 | title=title, 141 | body=body, 142 | labels=labels, 143 | is_open=is_open, 144 | priority=priority, 145 | issue_type=issue_type, 146 | milestones=milestones, 147 | components=components, 148 | comments=comments, 149 | metadata=metadata, 150 | raw_issue=raw_issue, 151 | ) 152 | 153 | def get_raw_issue_fields(self, fields, issue=None): 154 | fields = fields.copy() 155 | raw_fields = {} 156 | 157 | if "title" in fields: 158 | raw_fields["summary"] = fields.pop("title") 159 | 160 | if "body" in fields: 161 | if fields["body"]: 162 | body = fields["body"] 163 | else: 164 | body = "" 165 | raw_fields["description"] = body 166 | fields.pop("body") 167 | 168 | if "metadata" in fields: 169 | raw_fields.update(self._get_raw_metadata_fields(fields.pop("metadata"))) 170 | 171 | if "labels" in fields: 172 | if fields["labels"]: 173 | raw_fields["labels"] = [urllib.parse.quote(l) for l in fields["labels"]] 174 | else: 175 | raw_fields["labels"] = [] 176 | fields.pop("labels") 177 | 178 | if "milestones" in fields: 179 | if fields["milestones"]: 180 | raw_fields["fixVersions"] = self._make_name_list(fields["milestones"]) 181 | else: 182 | raw_fields["fixVersions"] = [] 183 | fields.pop("milestones") 184 | 185 | if "components" in fields: 186 | if fields["components"]: 187 | raw_fields["components"] = self._make_name_list(fields["components"]) 188 | else: 189 | raw_fields["components"] = [] 190 | fields.pop("components") 191 | 192 | if "priority" in fields: 193 | if fields["priority"]: 194 | raw_fields["priority"] = {"name": fields["priority"]} 195 | else: 196 | raw_fields["priority"] = None 197 | fields.pop("priority") 198 | 199 | if "issue_type" in fields: 200 | raw_fields["issuetype"] = {"name": fields.pop("issue_type")} 201 | 202 | transition = None 203 | if issue is None: 204 | if "is_open" in fields: 205 | if fields["is_open"]: 206 | status = self._config.jira.open_status 207 | else: 208 | status = self._config.jira.close_status 209 | else: 210 | status = self._config.jira.open_status 211 | if status: 212 | raw_fields["status"] = {"name": status} 213 | elif "is_open" in fields: 214 | if fields["is_open"]: 215 | transition = self._config.jira.reopen_status 216 | else: 217 | transition = self._config.jira.close_status 218 | fields.pop("is_open", None) 219 | 220 | raw_fields.update(fields) 221 | return raw_fields, transition 222 | 223 | def _make_name_list(self, values): 224 | return [{"name": v} for v in values] 225 | 226 | def _get_metadata(self, raw_issue): 227 | kwargs = {} 228 | 229 | field_name = _client_field_name(self._config.jira.github_issue_url_field_id) 230 | github_issue_url = getattr(raw_issue.fields, field_name) 231 | if github_issue_url: 232 | github_repository, github_issue_id = utils.extract_github_ids_from_url(github_issue_url) 233 | kwargs["github_repository"] = github_repository 234 | kwargs["github_issue_id"] = github_issue_id 235 | 236 | field_name = _client_field_name(self._config.jira.jirahub_metadata_field_id) 237 | metadata_json = getattr(raw_issue.fields, field_name) 238 | if metadata_json: 239 | try: 240 | metadata_dict = json.loads(metadata_json) 241 | except Exception: 242 | logger.exception("Failed to deserialize JSON") 243 | else: 244 | kwargs.update(metadata_dict) 245 | 246 | if kwargs.get("comments"): 247 | kwargs["comments"] = [CommentMetadata(**c) for c in kwargs["comments"]] 248 | 249 | return Metadata(**kwargs) 250 | 251 | def _get_raw_metadata_fields(self, metadata): 252 | raw_fields = {} 253 | 254 | if not metadata: 255 | return { 256 | _client_field_name(self._config.jira.github_issue_url_field_id): None, 257 | _client_field_name(self._config.jira.jirahub_metadata_field_id): None, 258 | } 259 | 260 | if metadata.github_repository and metadata.github_issue_id: 261 | github_issue_url = utils.make_github_issue_url(metadata.github_repository, metadata.github_issue_id) 262 | else: 263 | github_issue_url = None 264 | field_name = _client_field_name(self._config.jira.github_issue_url_field_id) 265 | raw_fields[field_name] = github_issue_url 266 | 267 | metadata_dict = dataclasses.asdict(metadata) 268 | metadata_dict.pop("github_repository") 269 | metadata_dict.pop("github_issue_id") 270 | field_name = _client_field_name(self._config.jira.jirahub_metadata_field_id) 271 | raw_fields[field_name] = json.dumps(metadata_dict) 272 | 273 | return raw_fields 274 | 275 | 276 | class Client: 277 | _PAGE_SIZE = 50 278 | 279 | @classmethod 280 | def from_config(cls, config): 281 | jira = JIRA( 282 | config.jira.server, basic_auth=(get_username(), get_password()), max_retries=config.jira.max_retries 283 | ) 284 | 285 | return cls(config, jira, get_username()) 286 | 287 | def __init__(self, config, jira, bot_username): 288 | self._config = config 289 | self._jira = jira 290 | self._mapper = _IssueMapper(config, bot_username) 291 | 292 | def get_user(self, username): 293 | return self._mapper.get_user(self._jira.user(username)) 294 | 295 | def find_issues(self, min_updated_at=None): 296 | if min_updated_at: 297 | assert min_updated_at.tzinfo is not None 298 | 299 | query = self._make_query(min_updated_at=min_updated_at) 300 | 301 | current_page = 0 302 | while True: 303 | start_idx = current_page * Client._PAGE_SIZE 304 | raw_issues = self._jira.search_issues(query, start_idx, Client._PAGE_SIZE) 305 | 306 | for raw_issue in raw_issues: 307 | # The JIRA client is a buggy and will occasionally return None 308 | # for the creator field, even when the data exists. Reloading the 309 | # issues one by one seems to fix that. 310 | raw_issue = self._jira.issue(raw_issue.key) 311 | raw_comments = self._jira.comments(raw_issue) 312 | yield self._mapper.get_issue(raw_issue, raw_comments) 313 | 314 | if len(raw_issues) < Client._PAGE_SIZE: 315 | break 316 | 317 | current_page += 1 318 | 319 | def find_other_issue(self, github_issue): 320 | assert github_issue.source == Source.GITHUB 321 | 322 | github_issue_url = utils.make_github_issue_url(github_issue.project, github_issue.issue_id) 323 | query = self._make_query(github_issue_url=github_issue_url) 324 | raw_issues = self._jira.search_issues(query) 325 | 326 | if len(raw_issues) > 1: 327 | raise RuntimeError(f"{github_issue} has multiple linked JIRA issues") 328 | elif len(raw_issues) == 1: 329 | # Reloading the issue to make sure we get the creator field (see note above). 330 | raw_issue = self._jira.issue(raw_issues[0].key) 331 | raw_comments = self._jira.comments(raw_issue) 332 | return self._mapper.get_issue(raw_issue, raw_comments) 333 | else: 334 | return None 335 | 336 | def get_issue(self, issue_id): 337 | raw_issue = self._jira.issue(issue_id) 338 | raw_comments = self._jira.comments(raw_issue) 339 | return self._mapper.get_issue(raw_issue, raw_comments) 340 | 341 | def create_issue(self, fields): 342 | raw_fields, _ = self._mapper.get_raw_issue_fields(fields) 343 | raw_fields["project"] = self._config.jira.project_key 344 | raw_issue = self._jira.create_issue(fields=raw_fields) 345 | new_issue = self._mapper.get_issue(raw_issue, []) 346 | 347 | logger.info("Created issue %s", new_issue) 348 | 349 | return new_issue 350 | 351 | def update_issue(self, issue, fields): 352 | assert issue.source == Source.JIRA 353 | 354 | if ("title" in fields or "body" in fields) and not issue.is_bot: 355 | raise ValueError("Cannot update title or body of issue owned by another user") 356 | 357 | raw_fields, transition = self._mapper.get_raw_issue_fields(fields, issue=issue) 358 | if len(raw_fields) > 0: 359 | issue.raw_issue.update(notify=self._config.jira.notify_watchers, fields=raw_fields) 360 | if transition is not None: 361 | self._jira.transition_issue(issue.raw_issue, transition) 362 | 363 | raw_comments = self._jira.comments(issue.raw_issue) 364 | updated_issue = self._mapper.get_issue(issue.raw_issue, raw_comments) 365 | 366 | logger.info("Updated issue %s", updated_issue) 367 | 368 | return updated_issue 369 | 370 | def create_comment(self, issue, fields): 371 | assert issue.source == Source.JIRA 372 | 373 | fields = self._mapper.get_raw_comment_fields(fields) 374 | raw_comment = self._jira.add_comment(issue=issue.issue_id, **fields) 375 | new_comment = self._mapper.get_comment(raw_comment) 376 | 377 | logger.info("Created comment %s on issue %s", new_comment, issue) 378 | 379 | return new_comment 380 | 381 | def update_comment(self, comment, fields): 382 | assert comment.source == Source.JIRA 383 | 384 | if not comment.is_bot: 385 | raise ValueError("Cannot update comment owned by another user") 386 | 387 | fields = self._mapper.get_raw_comment_fields(fields, comment=comment) 388 | comment.raw_comment.update(**fields) 389 | updated_comment = self._mapper.get_comment(comment.raw_comment) 390 | 391 | logger.info("Updated comment %s", updated_comment) 392 | 393 | return updated_comment 394 | 395 | def delete_comment(self, comment): 396 | assert comment.source == Source.JIRA 397 | 398 | if not comment.is_bot: 399 | raise ValueError("Cannot delete comment owned by another user") 400 | 401 | comment.raw_comment.delete() 402 | 403 | logger.info("Deleted comment %s", comment) 404 | 405 | def _make_query(self, min_updated_at=None, github_issue_url=None): 406 | filters = [] 407 | 408 | quoted_project_key = self._quote_query_string(self._config.jira.project_key) 409 | filters.append(f"project = {quoted_project_key}") 410 | 411 | if min_updated_at: 412 | min_updated_at_ms = int(min_updated_at.timestamp() * 1000) 413 | filters.append(f"updated > {min_updated_at_ms}") 414 | 415 | if github_issue_url: 416 | quoted_url = self._quote_query_string(github_issue_url) 417 | field_name = _jql_field_name(self._config.jira.github_issue_url_field_id) 418 | filters.append(f"{field_name} = {quoted_url}") 419 | 420 | return " and ".join(filters) + " order by updated asc" 421 | 422 | def _quote_query_string(self, value): 423 | return "'" + value.replace("'", "\\'") + "'" 424 | 425 | @lru_cache() 426 | def get_create_metadata(self, expand=None): 427 | return self._jira.createmeta(self._config.jira.project_key, expand=expand)["projects"][0] 428 | 429 | 430 | class Formatter: 431 | H1_RE = re.compile(r"(\s|^)# ") 432 | H2_RE = re.compile(r"(\s|^)## ") 433 | H3_RE = re.compile(r"(\s|^)### ") 434 | H4_RE = re.compile(r"(\s|^)#### ") 435 | H5_RE = re.compile(r"(\s|^)##### ") 436 | H6_RE = re.compile(r"(\s|^)###### ") 437 | NOFORMAT_OPEN_RE = re.compile(r"```(\w*)") 438 | NOFORMAT_CLOSE_RE = re.compile(r"```") 439 | HASH_NUMBER_RE = re.compile(r"(^|\s)#([0-9]+)($|\s)") 440 | USER_MENTION_RE = re.compile(r"(^|\s)@([0-9a-zA-Z-]+)\b") 441 | ITALIC_RE = re.compile(r"(^|[^\w*])\*(\w(.*?\w)?)\*($|[^\w*])") 442 | BOLD_RE = re.compile(r"(^|\W)\*\*(\w(.*?\w)?)\*\*($|\W)") 443 | MONOSPACED_RE = re.compile(r"`(.+?)`") 444 | STRIKETHROUGH_RE = re.compile(r"(^|\W)~~(\w(.*?\w)?)~~($|\W)") 445 | INSERTED_RE = re.compile(r"(.+?)") 446 | SUPERSCRIPT_RE = re.compile(r"(.+?)") 447 | SUBSCRIPT_RE = re.compile(r"(.+?)") 448 | URL_WITH_TEXT_RE = re.compile(r"\[(.*?)\]\((http.*?)\)") 449 | URL_RE = re.compile(r"(\s|^)?(\s|$)") 450 | QUOTE_RE = re.compile(r"((^> .*?$)(\r?\n)?)+", re.MULTILINE) 451 | 452 | def __init__(self, config, url_helper, github_client): 453 | self._config = config 454 | self._url_helper = url_helper 455 | self._github_client = github_client 456 | 457 | def format_link(self, url, link_text=None): 458 | if link_text: 459 | return f"[{link_text}|{url}]" 460 | else: 461 | return f"[{url}]" 462 | 463 | def format_body(self, body): 464 | regions = [(body, True)] 465 | 466 | regions = utils.isolate_regions( 467 | regions, Formatter.NOFORMAT_OPEN_RE, Formatter.NOFORMAT_CLOSE_RE, self._handle_noformat_content 468 | ) 469 | 470 | result = "" 471 | for content, formatted in regions: 472 | if formatted: 473 | content = self._format_content(content) 474 | result = result + content 475 | 476 | return result 477 | 478 | def _handle_noformat_content(self, content, open_match): 479 | if open_match.group(1): 480 | return ("{code:" + open_match.group(1) + "}" + content + "{code}", False) 481 | else: 482 | return ("{noformat}" + content + "{noformat}", False) 483 | 484 | def _format_user_mention(self, match): 485 | username = match.group(2) 486 | try: 487 | user = self._github_client.get_user(username) 488 | except Exception: 489 | logger.warning("Missing GitHub user with username %s", username) 490 | user = None 491 | 492 | if user: 493 | url = self._url_helper.get_user_profile_url(user) 494 | link_text = user.display_name 495 | return match.group(1) + self.format_link(url, link_text) 496 | else: 497 | return match.group(0) 498 | 499 | def _format_issue_or_pull(self, match): 500 | number = int(match.group(2)) 501 | 502 | if self._github_client.is_issue(number): 503 | url = self._url_helper.get_issue_url(source=Source.GITHUB, issue_id=number) 504 | link = self.format_link(url, f"#{number}") 505 | return match.group(1) + link + match.group(3) 506 | elif self._github_client.is_pull_request(number): 507 | url = self._url_helper.get_pull_request_url(number) 508 | link = self.format_link(url, f"#{number}") 509 | return match.group(1) + link + match.group(3) 510 | else: 511 | return match.group(0) 512 | 513 | def _format_quote_block(self, match): 514 | content = match.group(0) 515 | content = "{quote}\n" + content 516 | if content.endswith("\n"): 517 | content = content + "{quote}" 518 | else: 519 | content = content + "\n{quote}" 520 | 521 | lines = content.split("\n") 522 | 523 | new_lines = [] 524 | for line in lines: 525 | if line.startswith("> "): 526 | new_lines.append(line[2:]) 527 | else: 528 | new_lines.append(line) 529 | 530 | return "\n".join(new_lines) 531 | 532 | def _format_content(self, content): 533 | content = Formatter.HASH_NUMBER_RE.sub(self._format_issue_or_pull, content) 534 | content = Formatter.H1_RE.sub(lambda match: match.group(1) + "h1. ", content) 535 | content = Formatter.H2_RE.sub(lambda match: match.group(1) + "h2. ", content) 536 | content = Formatter.H3_RE.sub(lambda match: match.group(1) + "h3. ", content) 537 | content = Formatter.H4_RE.sub(lambda match: match.group(1) + "h4. ", content) 538 | content = Formatter.H5_RE.sub(lambda match: match.group(1) + "h5. ", content) 539 | content = Formatter.H6_RE.sub(lambda match: match.group(1) + "h6. ", content) 540 | content = Formatter.ITALIC_RE.sub( 541 | lambda match: match.group(1) + f"_{match.group(2)}_" + match.group(4), content 542 | ) 543 | content = Formatter.BOLD_RE.sub(lambda match: match.group(1) + f"*{match.group(2)}*" + match.group(4), content) 544 | content = Formatter.SUBSCRIPT_RE.sub(lambda match: f"~{match.group(1)}~", content) 545 | content = Formatter.MONOSPACED_RE.sub(lambda match: "{{" + match.group(1) + "}}", content) 546 | content = Formatter.STRIKETHROUGH_RE.sub( 547 | lambda match: match.group(1) + f"-{match.group(2)}-" + match.group(4), content 548 | ) 549 | content = Formatter.INSERTED_RE.sub(lambda match: f"+{match.group(1)}+", content) 550 | content = Formatter.SUPERSCRIPT_RE.sub(lambda match: f"^{match.group(1)}^", content) 551 | content = Formatter.USER_MENTION_RE.sub(self._format_user_mention, content) 552 | content = Formatter.URL_WITH_TEXT_RE.sub( 553 | lambda match: self.format_link(match.group(2), match.group(1)), content 554 | ) 555 | content = Formatter.URL_RE.sub( 556 | lambda match: match.group(1) + self.format_link(match.group(2)) + match.group(3), content 557 | ) 558 | content = Formatter.QUOTE_RE.sub(self._format_quote_block, content) 559 | 560 | return content 561 | -------------------------------------------------------------------------------- /jirahub/jirahub.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import dataclasses 3 | import inspect 4 | 5 | from .entities import Source, Metadata, CommentMetadata 6 | from .config import SyncFeature 7 | from .utils import UrlHelper 8 | 9 | from . import jira, github 10 | 11 | 12 | __all__ = ["IssueSync"] 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | _DEFAULT_ISSUE_TYPE = "Task" 19 | 20 | 21 | class IssueSync: 22 | @classmethod 23 | def from_config(cls, config, dry_run=False): 24 | jira_client = jira.Client.from_config(config) 25 | github_client = github.Client.from_config(config) 26 | 27 | return cls(config=config, jira_client=jira_client, github_client=github_client, dry_run=dry_run) 28 | 29 | def __init__(self, config, jira_client, github_client, dry_run=False): 30 | self._config = config 31 | 32 | self._client_by_source = {Source.JIRA: jira_client, Source.GITHUB: github_client} 33 | 34 | self.url_helper = UrlHelper.from_config(config) 35 | 36 | self._formatter_by_source = { 37 | Source.JIRA: jira.Formatter(config, self.url_helper, self.get_client(Source.GITHUB)), 38 | Source.GITHUB: github.Formatter(config, self.url_helper, self.get_client(Source.JIRA)), 39 | } 40 | 41 | self.dry_run = dry_run 42 | 43 | def get_source_config(self, source): 44 | return self._config.get_source_config(source) 45 | 46 | def get_client(self, source): 47 | return self._client_by_source[source] 48 | 49 | def get_formatter(self, source): 50 | return self._formatter_by_source[source] 51 | 52 | def get_project(self, source): 53 | if source == Source.JIRA: 54 | return self._config.jira.project_key 55 | else: 56 | return self._config.github.repository 57 | 58 | def accept_issue(self, source, issue): 59 | if issue.is_bot: 60 | return False 61 | 62 | issue_filter = self._config.get_source_config(source).issue_filter 63 | if issue_filter: 64 | return issue_filter(issue) 65 | else: 66 | return False 67 | 68 | def sync_feature_enabled(self, source, sync_feature): 69 | return self._config.is_enabled(source, sync_feature) 70 | 71 | def find_issues(self, min_updated_at=None, retry_issues=None): 72 | yield from self.get_client(Source.JIRA).find_issues(min_updated_at) 73 | yield from self.get_client(Source.GITHUB).find_issues(min_updated_at) 74 | 75 | if retry_issues is not None: 76 | for source, issue_id in retry_issues: 77 | yield self.get_client(source).get_issue(issue_id) 78 | 79 | def perform_sync(self, min_updated_at=None, retry_issues=None): 80 | if min_updated_at: 81 | assert min_updated_at.tzinfo is not None 82 | 83 | seen = set() 84 | failed = set() 85 | 86 | for updated_issue in self.find_issues(min_updated_at, retry_issues=retry_issues): 87 | if (updated_issue.source, updated_issue.issue_id) in seen: 88 | continue 89 | 90 | try: 91 | other_issue = self._perform_sync_issue(updated_issue) 92 | except Exception: 93 | logger.exception("Failed syncing %s", updated_issue) 94 | failed.add((updated_issue.source, updated_issue.issue_id)) 95 | else: 96 | seen.add((updated_issue.source, updated_issue.issue_id)) 97 | if other_issue: 98 | seen.add((other_issue.source, other_issue.issue_id)) 99 | 100 | return failed 101 | 102 | def _perform_sync_issue(self, updated_issue): 103 | other_source = updated_issue.source.other 104 | 105 | if ( 106 | updated_issue.source == Source.JIRA 107 | and updated_issue.metadata.github_repository 108 | and updated_issue.metadata.github_repository != self._config.github.repository 109 | ): 110 | logger.info( 111 | "%s was updated, but linked repository (%s) does not match configured repository (%s)", 112 | updated_issue, 113 | updated_issue.metadata.github_repository, 114 | self._config.github.repository, 115 | ) 116 | return None 117 | 118 | other_issue = self.get_client(other_source).find_other_issue(updated_issue) 119 | if other_issue: 120 | updated_issue_updates, other_issue_updates = self.make_issue_updates(updated_issue, other_issue) 121 | 122 | for hook in self._config.before_issue_update: 123 | args = [updated_issue, updated_issue_updates, other_issue, other_issue_updates] 124 | if len(inspect.signature(hook).parameters) > 4: 125 | args.append(self) 126 | updated_issue_updates, other_issue_updates = hook(*args) 127 | 128 | if updated_issue_updates: 129 | logger.info("Updating %s: %s", updated_issue, updated_issue_updates) 130 | if not self.dry_run: 131 | updated_issue = self.get_client(updated_issue.source).update_issue( 132 | updated_issue, updated_issue_updates 133 | ) 134 | else: 135 | logger.info("Skipping issue update due to dry run") 136 | 137 | if other_issue_updates: 138 | logger.info("Updating %s: %s", other_issue, other_issue_updates) 139 | if not self.dry_run: 140 | other_issue = self.get_client(other_issue.source).update_issue(other_issue, other_issue_updates) 141 | else: 142 | logger.info("Skipping issue update due to dry run") 143 | 144 | self.sync_comments(updated_issue, other_issue) 145 | 146 | return other_issue 147 | elif self.accept_issue(other_source, updated_issue): 148 | mirror_issue_fields = self.make_mirror_issue(updated_issue) 149 | 150 | for hook in self.get_source_config(other_source).before_issue_create: 151 | args = [updated_issue, mirror_issue_fields] 152 | if len(inspect.signature(hook).parameters) > 2: 153 | args.append(self) 154 | mirror_issue_fields = hook(*args) 155 | 156 | logger.info("Creating mirror of %s: %s", updated_issue, mirror_issue_fields) 157 | if not self.dry_run: 158 | mirror_issue = self.get_client(other_source).create_issue(mirror_issue_fields) 159 | 160 | updated_issue_updates, _ = self.make_issue_updates(updated_issue, mirror_issue) 161 | if updated_issue_updates: 162 | logger.info("Updating %s: %s", updated_issue, updated_issue_updates) 163 | updated_issue = self.get_client(updated_issue.source).update_issue( 164 | updated_issue, updated_issue_updates 165 | ) 166 | 167 | self.sync_comments(updated_issue, mirror_issue) 168 | return mirror_issue 169 | else: 170 | logger.info("Skipping issue create due to dry run") 171 | return None 172 | else: 173 | return None 174 | 175 | def make_issue_updates(self, issue_one, issue_two): 176 | one_updates = {} 177 | two_updates = {} 178 | 179 | if issue_one.is_bot or issue_two.is_bot: 180 | if issue_one.is_bot: 181 | source_issue = issue_two 182 | mirror_issue = issue_one 183 | mirror_updates = one_updates 184 | else: 185 | source_issue = issue_one 186 | mirror_issue = issue_two 187 | mirror_updates = two_updates 188 | 189 | expected_mirror_title = self.make_mirror_issue_title(source_issue) 190 | if expected_mirror_title != mirror_issue.title: 191 | mirror_updates["title"] = expected_mirror_title 192 | 193 | expected_mirror_body = self.make_mirror_issue_body(source_issue) 194 | if expected_mirror_body != mirror_issue.body: 195 | mirror_updates["body"] = expected_mirror_body 196 | 197 | fields = [ 198 | ("is_open", SyncFeature.SYNC_STATUS), 199 | ("milestones", SyncFeature.SYNC_MILESTONES), 200 | ("labels", SyncFeature.SYNC_LABELS), 201 | ] 202 | 203 | for field_name, sync_feature in fields: 204 | one_field_updates, two_field_updates = self._make_field_updates( 205 | issue_one, issue_two, field_name, sync_feature 206 | ) 207 | one_updates.update(one_field_updates) 208 | two_updates.update(two_field_updates) 209 | 210 | if issue_one.source == Source.JIRA: 211 | jira_issue = issue_one 212 | jira_updates = one_updates 213 | github_issue = issue_two 214 | else: 215 | jira_issue = issue_two 216 | jira_updates = two_updates 217 | github_issue = issue_one 218 | 219 | if ( 220 | jira_issue.metadata.github_repository != github_issue.project 221 | or jira_issue.metadata.github_issue_id != github_issue.issue_id 222 | ): 223 | metadata = Metadata( 224 | github_repository=github_issue.project, 225 | github_issue_id=github_issue.issue_id, 226 | comments=jira_issue.metadata.comments, 227 | ) 228 | jira_updates["metadata"] = metadata 229 | 230 | return one_updates, two_updates 231 | 232 | def _make_field_updates(self, issue_one, issue_two, field_name, sync_feature): 233 | one_enabled = self.sync_feature_enabled(issue_one.source, sync_feature) 234 | two_enabled = self.sync_feature_enabled(issue_two.source, sync_feature) 235 | 236 | one_updates = {} 237 | two_updates = {} 238 | 239 | if one_enabled or two_enabled: 240 | one_value = getattr(issue_one, field_name) 241 | two_value = getattr(issue_two, field_name) 242 | 243 | if not one_value == two_value: 244 | if one_enabled and two_enabled: 245 | if issue_one.updated_at > issue_two.updated_at: 246 | two_updates[field_name] = one_value 247 | else: 248 | one_updates[field_name] = two_value 249 | elif one_enabled: 250 | one_updates[field_name] = two_value 251 | elif two_enabled: 252 | two_updates[field_name] = one_value 253 | 254 | return one_updates, two_updates 255 | 256 | def sync_comments(self, issue_one, issue_two): 257 | if issue_one.source == Source.JIRA: 258 | jira_issue = issue_one 259 | else: 260 | jira_issue = issue_two 261 | 262 | rebuild_comment_metadata = False 263 | 264 | tracking_comment_ids_by_source = { 265 | Source.GITHUB: jira_issue.metadata.github_tracking_comment_id, 266 | Source.JIRA: jira_issue.metadata.jira_tracking_comment_id, 267 | } 268 | 269 | for issue, other_issue in [(issue_one, issue_two), (issue_two, issue_one)]: 270 | if self.get_source_config(issue.source).create_tracking_comment and not issue.is_bot: 271 | tracking_comment_id = tracking_comment_ids_by_source[issue.source] 272 | tracking_comment = next((c for c in issue.comments if c.comment_id == tracking_comment_id), None) 273 | 274 | if tracking_comment is None: 275 | tracking_comment_fields = self.make_tracking_comment(other_issue) 276 | 277 | logger.info("Creating tracking comment on %s: %s", issue, tracking_comment_fields) 278 | 279 | if not self.dry_run: 280 | tracking_comment = self.get_client(issue.source).create_comment(issue, tracking_comment_fields) 281 | tracking_comment_ids_by_source[issue.source] = tracking_comment.comment_id 282 | else: 283 | logger.info("Skipping comment create due to dry run") 284 | 285 | rebuild_comment_metadata = True 286 | else: 287 | expected_comment_body = self.make_tracking_comment_body(other_issue) 288 | if tracking_comment.body != expected_comment_body: 289 | tracking_comment_updates = {"body": expected_comment_body} 290 | 291 | logger.info("Updating %s on %s: %s", tracking_comment, issue, tracking_comment_updates) 292 | 293 | if not self.dry_run: 294 | self.get_client(issue.source).update_comment(tracking_comment, tracking_comment_updates) 295 | else: 296 | logger.info("Skipping comment update due to dry run") 297 | 298 | tracking_comment_ids = set(tracking_comment_ids_by_source.values()) 299 | issue_one_comments = [ 300 | (issue_one, c, issue_two) for c in issue_one.comments if c.comment_id not in tracking_comment_ids 301 | ] 302 | issue_two_comments = [ 303 | (issue_two, c, issue_one) for c in issue_two.comments if c.comment_id not in tracking_comment_ids 304 | ] 305 | all_comments = issue_one_comments + issue_two_comments 306 | comments_by_id = {(c.source, c.comment_id): c for _, c, _ in all_comments} 307 | 308 | comments_by_linked_id = {} 309 | for comment_metadata in jira_issue.metadata.comments: 310 | jira_comment = comments_by_id.get((Source.JIRA, comment_metadata.jira_comment_id)) 311 | github_comment = comments_by_id.get((Source.GITHUB, comment_metadata.github_comment_id)) 312 | 313 | if jira_comment and github_comment: 314 | comments_by_linked_id[(Source.JIRA, jira_comment.comment_id)] = github_comment 315 | comments_by_linked_id[(Source.GITHUB, github_comment.comment_id)] = jira_comment 316 | else: 317 | rebuild_comment_metadata = True 318 | 319 | for issue, comment, other_issue in all_comments: 320 | try: 321 | if comment.is_bot: 322 | source_comment = comments_by_linked_id.get((comment.source, comment.comment_id)) 323 | if not source_comment and self.sync_feature_enabled(comment.source, SyncFeature.SYNC_COMMENTS): 324 | logger.info("Deleting %s on %s", comment, issue) 325 | 326 | if not self.dry_run: 327 | self.get_client(comment.source).delete_comment(comment) 328 | else: 329 | logger.info("Skipping comment delete due to dry run") 330 | 331 | rebuild_comment_metadata = True 332 | else: 333 | mirror_comment = comments_by_linked_id.get((comment.source, comment.comment_id)) 334 | if mirror_comment: 335 | if self.sync_feature_enabled(mirror_comment.source, SyncFeature.SYNC_COMMENTS): 336 | expected_mirror_body = self.make_mirror_comment_body(issue, comment) 337 | if mirror_comment.body != expected_mirror_body: 338 | mirror_comment_updates = {"body": expected_mirror_body} 339 | 340 | logger.info( 341 | "Updating %s on %s: %s", mirror_comment, other_issue, mirror_comment_updates 342 | ) 343 | 344 | if not self.dry_run: 345 | self.get_client(other_issue.source).update_comment( 346 | mirror_comment, mirror_comment_updates 347 | ) 348 | else: 349 | logger.info("Skipping comment update due to dry run") 350 | 351 | else: 352 | if self.sync_feature_enabled(other_issue.source, SyncFeature.SYNC_COMMENTS): 353 | mirror_comment_fields = self.make_mirror_comment(issue, comment, other_issue) 354 | 355 | logger.info("Creating comment on %s: %s", other_issue, mirror_comment_fields) 356 | 357 | if not self.dry_run: 358 | mirror_comment = self.get_client(other_issue.source).create_comment( 359 | other_issue, mirror_comment_fields 360 | ) 361 | comments_by_linked_id[(mirror_comment.source, mirror_comment.comment_id)] = comment 362 | comments_by_linked_id[(comment.source, comment.comment_id)] = mirror_comment 363 | else: 364 | logger.info("Skipping comment create due to dry run") 365 | 366 | rebuild_comment_metadata = True 367 | except Exception: 368 | logger.exception("Failed syncing %s", comment) 369 | 370 | if rebuild_comment_metadata: 371 | new_comment_metadata_list = [] 372 | for (source, comment_id), comment in comments_by_linked_id.items(): 373 | if source == Source.JIRA: 374 | new_comment_metadata_list.append( 375 | CommentMetadata(jira_comment_id=comment_id, github_comment_id=comment.comment_id) 376 | ) 377 | 378 | kwargs = dataclasses.asdict(jira_issue.metadata) 379 | kwargs["comments"] = new_comment_metadata_list 380 | kwargs["jira_tracking_comment_id"] = tracking_comment_ids_by_source[Source.JIRA] 381 | kwargs["github_tracking_comment_id"] = tracking_comment_ids_by_source[Source.GITHUB] 382 | 383 | new_metadata = Metadata(**kwargs) 384 | 385 | if not self.dry_run: 386 | self.get_client(Source.JIRA).update_issue(jira_issue, {"metadata": new_metadata}) 387 | else: 388 | logger.info("Skipping comment metadata update due to dry run") 389 | 390 | def make_mirror_comment(self, source_issue, source_comment, mirror_issue): 391 | fields = {} 392 | 393 | fields["body"] = self.make_mirror_comment_body(source_issue, source_comment) 394 | 395 | return fields 396 | 397 | def make_tracking_comment(self, source_issue): 398 | fields = {} 399 | 400 | fields["body"] = self.make_tracking_comment_body(source_issue) 401 | 402 | return fields 403 | 404 | def make_mirror_issue(self, source_issue): 405 | mirror_source = source_issue.source.other 406 | 407 | fields = {} 408 | 409 | fields["title"] = self.make_mirror_issue_title(source_issue) 410 | fields["body"] = self.make_mirror_issue_body(source_issue) 411 | 412 | if self.sync_feature_enabled(mirror_source, SyncFeature.SYNC_LABELS): 413 | fields["labels"] = source_issue.labels.copy() 414 | 415 | if self.sync_feature_enabled(mirror_source, SyncFeature.SYNC_MILESTONES): 416 | fields["milestones"] = source_issue.milestones.copy() 417 | 418 | if mirror_source == Source.JIRA: 419 | fields["issue_type"] = _DEFAULT_ISSUE_TYPE 420 | 421 | metadata = Metadata(github_repository=source_issue.project, github_issue_id=source_issue.issue_id) 422 | fields["metadata"] = metadata 423 | 424 | return fields 425 | 426 | def make_mirror_issue_title(self, source_issue): 427 | title = self.redact_text(source_issue.source.other, source_issue.title) 428 | 429 | custom_formatter = self.get_source_config(source_issue.source.other).issue_title_formatter 430 | if custom_formatter: 431 | return custom_formatter(source_issue, title) 432 | else: 433 | return title 434 | 435 | def make_mirror_issue_body(self, source_issue): 436 | mirror_source = source_issue.source.other 437 | formatter = self.get_formatter(mirror_source) 438 | 439 | body = self.redact_text(mirror_source, source_issue.body) 440 | body = formatter.format_body(body) 441 | 442 | custom_formatter = self.get_source_config(mirror_source).issue_body_formatter 443 | if custom_formatter: 444 | return custom_formatter(source_issue, body) 445 | else: 446 | user_url = self.url_helper.get_user_profile_url(source_issue.user) 447 | user_link = formatter.format_link(user_url, source_issue.user.display_name) 448 | 449 | if source_issue.source == Source.JIRA: 450 | link_text = f"{source_issue.issue_id}" 451 | else: 452 | link_text = f"#{source_issue.issue_id}" 453 | 454 | issue_url = self.url_helper.get_issue_url(source_issue) 455 | issue_link = formatter.format_link(issue_url, link_text) 456 | 457 | return f"_Issue {issue_link} was created on {source_issue.source} by {user_link}:_\r\n\r\n{body}" 458 | 459 | def make_mirror_comment_body(self, source_issue, source_comment): 460 | mirror_source = source_comment.source.other 461 | formatter = self.get_formatter(mirror_source) 462 | 463 | body = self.redact_text(mirror_source, source_comment.body) 464 | body = formatter.format_body(body) 465 | 466 | custom_formatter = self.get_source_config(mirror_source).comment_body_formatter 467 | if custom_formatter: 468 | return custom_formatter(source_issue, source_comment, body) 469 | else: 470 | user_url = self.url_helper.get_user_profile_url(source_comment.user) 471 | user_link = formatter.format_link(user_url, source_comment.user.display_name) 472 | 473 | comment_url = self.url_helper.get_comment_url(source_issue, source_comment) 474 | comment_link = formatter.format_link(comment_url, str(source_issue.source)) 475 | 476 | return f"_Comment by {user_link} on {comment_link}:_\r\n\r\n{body}" 477 | 478 | def make_tracking_comment_body(self, source_issue): 479 | mirror_source = source_issue.source.other 480 | formatter = self.get_formatter(mirror_source) 481 | 482 | if source_issue.source == Source.JIRA: 483 | link_text = f"{source_issue.issue_id}" 484 | else: 485 | link_text = f"#{source_issue.issue_id}" 486 | 487 | issue_url = self.url_helper.get_issue_url(source_issue) 488 | issue_link = formatter.format_link(issue_url, link_text) 489 | 490 | return f"_This issue is tracked on {source_issue.source} as {issue_link}._" 491 | 492 | def redact_text(self, source, text): 493 | for pattern in self.get_source_config(source).redact_patterns: 494 | for match in pattern.finditer(text): 495 | text = text[: match.start()] + "\u2588" * (match.end() - match.start()) + text[match.end() :] 496 | 497 | return text 498 | -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | from dataclasses import dataclass, field 3 | from typing import List, Any, Dict 4 | from jira.exceptions import JIRAError 5 | import requests 6 | import re 7 | 8 | from jirahub.entities import Issue, Comment, User, Source 9 | 10 | from github import BadCredentialsException, UnknownObjectException 11 | from github.GithubObject import NotSet 12 | 13 | from . import constants 14 | 15 | 16 | def reset(): 17 | MockJIRA.reset() 18 | MockGithub.reset() 19 | 20 | 21 | def now(): 22 | return datetime.utcnow().replace(tzinfo=timezone.utc) 23 | 24 | 25 | def next_github_issue_id(): 26 | result = next_github_issue_id._next_id 27 | next_github_issue_id._next_id += 1 28 | return result 29 | 30 | 31 | next_github_issue_id._next_id = 1 32 | 33 | 34 | def next_jira_issue_id(): 35 | result = f"{constants.TEST_JIRA_PROJECT_KEY}-{next_jira_issue_id._next_id}" 36 | next_jira_issue_id._next_id += 1 37 | return result 38 | 39 | 40 | next_jira_issue_id._next_id = 1 41 | 42 | 43 | def next_comment_id(): 44 | result = next_comment_id._next_id 45 | next_comment_id._next_id += 1 46 | return result 47 | 48 | 49 | next_comment_id._next_id = 1 50 | 51 | 52 | _JIRA_TZ = timezone(timedelta(hours=-4)) 53 | 54 | 55 | def _jira_format_datetime(dt): 56 | if dt.tzinfo is None: 57 | dt = dt.replace(tzinfo=timezone.utc) 58 | 59 | dt = dt.astimezone(_JIRA_TZ) 60 | 61 | ms = int(dt.microsecond / 1e3) 62 | 63 | return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{ms:03d}" + dt.strftime("%z") 64 | 65 | 66 | def _jira_now(): 67 | return _jira_format_datetime(now()) 68 | 69 | 70 | def _jira_named_object_list(values): 71 | return [MockJIRANamedObject(name=v["name"]) for v in values] 72 | 73 | 74 | def _jira_named_object(value): 75 | if value is None: 76 | return None 77 | else: 78 | return MockJIRANamedObject(name=value["name"]) 79 | 80 | 81 | def _jira_process_issue_fields(fields): 82 | fields = fields.copy() 83 | 84 | if "fixVersions" in fields: 85 | fields["fixVersions"] = _jira_named_object_list(fields["fixVersions"]) 86 | 87 | if "components" in fields: 88 | fields["components"] = _jira_named_object_list(fields["components"]) 89 | 90 | if "priority" in fields: 91 | fields["priority"] = _jira_named_object(fields["priority"]) 92 | 93 | if "issuetype" in fields: 94 | fields["issuetype"] = _jira_named_object(fields["issuetype"]) 95 | 96 | if "status" in fields: 97 | fields["status"] = _jira_named_object(fields["status"]) 98 | 99 | return fields 100 | 101 | 102 | def _bot_jira_user(): 103 | return MockJIRAUser(name=constants.TEST_JIRA_USERNAME, displayName=constants.TEST_JIRA_USER_DISPLAY_NAME) 104 | 105 | 106 | class MockLogger: 107 | def __init__(self): 108 | self.warnings = [] 109 | self.errors = [] 110 | self.infos = [] 111 | 112 | def warning(self, *args): 113 | self.warnings.append(args) 114 | 115 | def error(self, *args): 116 | self.errors.append(args) 117 | 118 | def info(self, *args): 119 | self.infos.append(args) 120 | 121 | 122 | @dataclass 123 | class MockJIRAUser: 124 | name: str 125 | displayName: str 126 | 127 | 128 | @dataclass 129 | class MockJIRAComment: 130 | body: str 131 | issue_key: str 132 | jira: Any 133 | id: int = field(default_factory=next_comment_id) 134 | author: MockJIRAUser = field(default_factory=_bot_jira_user) 135 | created: str = field(default_factory=_jira_now) 136 | updated: str = field(default_factory=_jira_now) 137 | 138 | def update(self, fields=None, async_=None, jira=None, body="", visibility=None): 139 | self.body = body 140 | self.updated = _jira_now() 141 | 142 | def delete(self, params=None): 143 | self.jira.comments_list.remove(self) 144 | 145 | 146 | @dataclass 147 | class MockJIRANamedObject: 148 | name: str 149 | 150 | 151 | @dataclass 152 | class MockJIRAProject: 153 | key: str = constants.TEST_JIRA_PROJECT_KEY 154 | 155 | 156 | @dataclass 157 | class MockJIRAIssueFields: 158 | summary: str 159 | creator: MockJIRAUser = field(default_factory=_bot_jira_user) 160 | description: str = None 161 | labels: List[str] = field(default_factory=list) 162 | fixVersions: List[MockJIRANamedObject] = field(default_factory=list) 163 | components: List[MockJIRANamedObject] = field(default_factory=list) 164 | priority: MockJIRANamedObject = field( 165 | default_factory=lambda: MockJIRANamedObject(name=constants.TEST_JIRA_DEFAULT_PRIORITY) 166 | ) 167 | issuetype: MockJIRANamedObject = field( 168 | default_factory=lambda: MockJIRANamedObject(name=constants.TEST_JIRA_DEFAULT_ISSUE_TYPE) 169 | ) 170 | status: MockJIRANamedObject = field( 171 | default_factory=lambda: MockJIRANamedObject(name=constants.TEST_JIRA_DEFAULT_STATUS) 172 | ) 173 | created: str = field(default_factory=_jira_now) 174 | updated: str = field(default_factory=_jira_now) 175 | customfield_12345: str = None 176 | customfield_67890: str = None 177 | project: MockJIRAProject = field(default_factory=lambda: MockJIRAProject()) 178 | custom_field: str = None 179 | 180 | 181 | @dataclass 182 | class MockJIRAIssue: 183 | fields: MockJIRAIssueFields 184 | key: str = field(default_factory=next_jira_issue_id) 185 | 186 | def update(self, fields=None, update=None, async_=None, jira=None, notify=True): 187 | fields = _jira_process_issue_fields(fields) 188 | 189 | for key, value in fields.items(): 190 | setattr(self.fields, key, value) 191 | 192 | self.fields.updated = _jira_now() 193 | 194 | def _transition(self, status): 195 | self.fields.status = MockJIRANamedObject(name=status) 196 | self.fields.updated = _jira_now() 197 | 198 | 199 | class MockJIRA: 200 | ALL_PERMISSIONS = [ 201 | "VIEW_WORKFLOW_READONLY", 202 | "CREATE_ISSUES", 203 | "VIEW_DEV_TOOLS", 204 | "BULK_CHANGE", 205 | "CREATE_ATTACHMENT", 206 | "DELETE_OWN_COMMENTS", 207 | "WORK_ON_ISSUES", 208 | "PROJECT_ADMIN", 209 | "COMMENT_EDIT_ALL", 210 | "ATTACHMENT_DELETE_OWN", 211 | "WORKLOG_DELETE_OWN", 212 | "CLOSE_ISSUE", 213 | "MANAGE_WATCHER_LIST", 214 | "VIEW_VOTERS_AND_WATCHERS", 215 | "ADD_COMMENTS", 216 | "COMMENT_DELETE_ALL", 217 | "CREATE_ISSUE", 218 | "DELETE_OWN_ATTACHMENTS", 219 | "DELETE_ALL_ATTACHMENTS", 220 | "ASSIGN_ISSUE", 221 | "LINK_ISSUE", 222 | "EDIT_OWN_WORKLOGS", 223 | "CREATE_ATTACHMENTS", 224 | "EDIT_ALL_WORKLOGS", 225 | "SCHEDULE_ISSUE", 226 | "CLOSE_ISSUES", 227 | "SET_ISSUE_SECURITY", 228 | "SCHEDULE_ISSUES", 229 | "WORKLOG_DELETE_ALL", 230 | "COMMENT_DELETE_OWN", 231 | "ADMINISTER_PROJECTS", 232 | "DELETE_ALL_COMMENTS", 233 | "RESOLVE_ISSUES", 234 | "VIEW_READONLY_WORKFLOW", 235 | "ADMINISTER", 236 | "MOVE_ISSUES", 237 | "TRANSITION_ISSUES", 238 | "SYSTEM_ADMIN", 239 | "DELETE_OWN_WORKLOGS", 240 | "BROWSE", 241 | "EDIT_ISSUE", 242 | "MODIFY_REPORTER", 243 | "EDIT_ISSUES", 244 | "MANAGE_WATCHERS", 245 | "EDIT_OWN_COMMENTS", 246 | "ASSIGN_ISSUES", 247 | "BROWSE_PROJECTS", 248 | "VIEW_VERSION_CONTROL", 249 | "WORK_ISSUE", 250 | "COMMENT_ISSUE", 251 | "WORKLOG_EDIT_ALL", 252 | "EDIT_ALL_COMMENTS", 253 | "DELETE_ISSUE", 254 | "MANAGE_SPRINTS_PERMISSION", 255 | "USER_PICKER", 256 | "CREATE_SHARED_OBJECTS", 257 | "ATTACHMENT_DELETE_ALL", 258 | "DELETE_ISSUES", 259 | "MANAGE_GROUP_FILTER_SUBSCRIPTIONS", 260 | "RESOLVE_ISSUE", 261 | "ASSIGNABLE_USER", 262 | "TRANSITION_ISSUE", 263 | "COMMENT_EDIT_OWN", 264 | "MOVE_ISSUE", 265 | "WORKLOG_EDIT_OWN", 266 | "DELETE_ALL_WORKLOGS", 267 | "LINK_ISSUES", 268 | ] 269 | 270 | UPDATED_RE = re.compile(r"\bupdated > ([0-9]+)\b") 271 | ISSUE_URL_RE = re.compile(r"\bgithub_issue_url = '(.*?)'") 272 | 273 | valid_servers = [] 274 | valid_basic_auths = [] 275 | valid_project_keys = [] 276 | permissions = [] 277 | 278 | @classmethod 279 | def reset(cls): 280 | cls.valid_servers = [constants.TEST_JIRA_SERVER] 281 | cls.valid_basic_auths = [(constants.TEST_JIRA_USERNAME, constants.TEST_JIRA_PASSWORD)] 282 | cls.valid_project_keys = [constants.TEST_JIRA_PROJECT_KEY] 283 | cls.permissions = cls.ALL_PERMISSIONS.copy() 284 | 285 | def __init__(self, server, basic_auth, max_retries=3): 286 | self.server = server 287 | self.basic_auth = basic_auth 288 | self.max_retries = max_retries 289 | 290 | if self.server not in MockJIRA.valid_servers: 291 | raise requests.exceptions.ConnectionError 292 | 293 | if self.basic_auth not in MockJIRA.valid_basic_auths: 294 | raise JIRAError() 295 | 296 | self.issues = [] 297 | self.comments_list = [] 298 | self.users = [_bot_jira_user()] 299 | 300 | def my_permissions(self, projectKey): 301 | if projectKey not in MockJIRA.valid_project_keys: 302 | raise JIRAError() 303 | 304 | permissions = {} 305 | for permission_id, permission in enumerate(MockJIRA.ALL_PERMISSIONS): 306 | entry = { 307 | "id": str(permission_id), 308 | "key": permission, 309 | "name": permission, 310 | "description": permission, 311 | "havePermission": permission in MockJIRA.permissions, 312 | } 313 | permissions[permission] = entry 314 | 315 | return {"permissions": permissions} 316 | 317 | def search_issues(self, jql_str, startAt=0, maxResults=50): 318 | updated_match = MockJIRA.UPDATED_RE.search(jql_str) 319 | issue_url_match = MockJIRA.ISSUE_URL_RE.search(jql_str) 320 | 321 | if updated_match: 322 | ms = int(updated_match.group(1)) / 1000.0 323 | dt = datetime.fromtimestamp(ms, tz=timezone.utc) 324 | min_updated = _jira_format_datetime(dt) 325 | # Times in ISO-8601 in the same timezone are comparable lexicographically: 326 | issues = [i for i in self.issues if i.fields.updated >= min_updated] 327 | elif issue_url_match: 328 | github_issue_url = issue_url_match.group(1) 329 | issues = [i for i in self.issues if github_issue_url in i.fields.github_issue_url] 330 | else: 331 | issues = self.issues 332 | 333 | return issues[startAt : startAt + maxResults] 334 | 335 | def comments(self, issue): 336 | return [c for c in self.comments_list if c.issue_key == issue.key] 337 | 338 | def issue(self, id): 339 | try: 340 | return next(i for i in self.issues if i.key == id) 341 | except StopIteration: 342 | raise JIRAError() 343 | 344 | def create_issue(self, fields): 345 | fields = _jira_process_issue_fields(fields) 346 | 347 | project = fields.pop("project") 348 | assert project in MockJIRA.valid_project_keys 349 | 350 | fields = MockJIRAIssueFields(**fields) 351 | issue = MockJIRAIssue(fields=fields) 352 | self.issues.append(issue) 353 | return issue 354 | 355 | def add_comment(self, issue, body): 356 | comment = MockJIRAComment(jira=self, issue_key=issue, body=body) 357 | self.comments_list.append(comment) 358 | return comment 359 | 360 | def user(self, username): 361 | try: 362 | return next(u for u in self.users if u.name == username) 363 | except StopIteration: 364 | raise JIRAError() 365 | 366 | def transition_issue(self, issue, status): 367 | issue._transition(status) 368 | 369 | PROJECT_METADATA = object() 370 | 371 | def createmeta( 372 | self, projectKeys=None, projectIds=[], issuetypeIds=None, issuetypeNames=None, expand=None, 373 | ): 374 | return {"projects": [self.PROJECT_METADATA]} 375 | 376 | 377 | MockJIRA.reset() 378 | 379 | 380 | def _bot_github_user(): 381 | return MockGithubUser(login=constants.TEST_GITHUB_USER_LOGIN, name=constants.TEST_GITHUB_USER_NAME) 382 | 383 | 384 | @dataclass 385 | class MockGithubPermissions: 386 | admin: bool = False 387 | pull: bool = True 388 | push: bool = True 389 | 390 | 391 | @dataclass 392 | class MockGithubMilestone: 393 | title: str 394 | 395 | 396 | @dataclass 397 | class MockGithubUser: 398 | login: str 399 | name: str 400 | 401 | 402 | @dataclass 403 | class MockGithubComment: 404 | body: str 405 | issue: Any 406 | id: int = field(default_factory=next_comment_id) 407 | user: MockGithubUser = field(default_factory=_bot_github_user) 408 | created_at: datetime = field(default_factory=datetime.utcnow) 409 | updated_at: datetime = field(default_factory=datetime.utcnow) 410 | 411 | def edit(self, body): 412 | self.body = body 413 | self.updated_at = datetime.utcnow() 414 | 415 | def delete(self): 416 | self.issue.comments.remove(self) 417 | 418 | 419 | @dataclass 420 | class MockGithubLabel: 421 | name: str 422 | 423 | 424 | @dataclass 425 | class MockGithubPull: 426 | title: str 427 | number: int = field(default_factory=next_github_issue_id) 428 | 429 | 430 | @dataclass 431 | class MockGithubIssue: 432 | body: str 433 | title: str 434 | repository: Any 435 | number: int = field(default_factory=next_github_issue_id) 436 | user: MockGithubUser = field(default_factory=_bot_github_user) 437 | state: str = "open" 438 | created_at: datetime = field(default_factory=datetime.utcnow) 439 | updated_at: datetime = field(default_factory=datetime.utcnow) 440 | labels: List[MockGithubLabel] = field(default_factory=list) 441 | milestone: MockGithubMilestone = None 442 | comments: List[MockGithubComment] = field(default_factory=list) 443 | pull_request: Dict[str, Any] = None 444 | assignee: str = None 445 | 446 | def edit(self, body=NotSet, title=NotSet, state=NotSet, labels=NotSet, milestone=NotSet): 447 | if body != NotSet: 448 | self.body = body 449 | 450 | if title != NotSet: 451 | self.title = title 452 | 453 | if state != NotSet: 454 | self.state = state 455 | 456 | if labels != NotSet: 457 | self.labels = [MockGithubLabel(name=l) for l in labels] 458 | 459 | if milestone != NotSet: 460 | self.milestone = milestone 461 | 462 | self.updated_at = datetime.utcnow() 463 | 464 | def create_comment(self, body): 465 | comment = MockGithubComment(body=body, issue=self) 466 | 467 | self.comments.append(comment) 468 | 469 | return comment 470 | 471 | def get_comments(self): 472 | return self.comments 473 | 474 | 475 | @dataclass 476 | class MockGithubRepository: 477 | full_name: str = constants.TEST_GITHUB_REPOSITORY 478 | name: str = constants.TEST_GITHUB_REPOSITORY_NAME 479 | permissions: MockGithubPermissions = field(default_factory=lambda: MockGithubPermissions()) 480 | milestones: List[MockGithubMilestone] = field(default_factory=list) 481 | issues: List[MockGithubIssue] = field(default_factory=list) 482 | pulls: List[MockGithubPull] = field(default_factory=list) 483 | 484 | def get_milestones(self): 485 | return self.milestones 486 | 487 | def get_issues(self, sort=None, direction=None, since=None, state=None): 488 | assert state == "all" 489 | 490 | for issue in self.issues: 491 | if since is None or issue.updated_at >= since.replace(tzinfo=None): 492 | yield issue 493 | 494 | def get_issue(self, number): 495 | try: 496 | return next(i for i in self.issues if i.number == number) 497 | except StopIteration: 498 | raise UnknownObjectException(404, {}) 499 | 500 | def create_issue(self, title, body=None, milestone=None, labels=[], assignee=None): 501 | issue = MockGithubIssue( 502 | body=body, 503 | title=title, 504 | labels=[MockGithubLabel(name=l) for l in labels], 505 | milestone=milestone, 506 | repository=self, 507 | assignee=assignee, 508 | ) 509 | 510 | self.issues.append(issue) 511 | 512 | return issue 513 | 514 | def get_pull(self, number): 515 | try: 516 | return next(p for p in self.pulls if p.number == number) 517 | except StopIteration: 518 | raise UnknownObjectException(404, {}) 519 | 520 | 521 | @dataclass 522 | class MockGithub: 523 | token: str = constants.TEST_GITHUB_TOKEN 524 | retry: int = 3 525 | users: List[MockGithubUser] = field(default_factory=lambda: [_bot_github_user()]) 526 | 527 | repositories = [] 528 | valid_tokens = [] 529 | 530 | @classmethod 531 | def reset(cls): 532 | cls.repositories = [ 533 | MockGithubRepository( 534 | full_name=constants.TEST_GITHUB_REPOSITORY, 535 | name=constants.TEST_GITHUB_REPOSITORY_NAME, 536 | permissions=MockGithubPermissions(admin=False, pull=True, push=True), 537 | milestones=[MockGithubMilestone(title="7.0.1"), MockGithubMilestone(title="8.5.3")], 538 | ) 539 | ] 540 | cls.valid_tokens = [constants.TEST_GITHUB_TOKEN] 541 | 542 | def get_repo(self, full_name): 543 | if self.token not in MockGithub.valid_tokens: 544 | raise BadCredentialsException(400, {}) 545 | 546 | try: 547 | return next(r for r in MockGithub.repositories if r.full_name == full_name) 548 | except StopIteration: 549 | raise UnknownObjectException(404, {}) 550 | 551 | def get_user(self, username=constants.TEST_GITHUB_USER_LOGIN): 552 | try: 553 | return next(u for u in self.users if u.login == username) 554 | except StopIteration: 555 | raise UnknownObjectException(404, {}) 556 | 557 | 558 | MockGithub.reset() 559 | 560 | 561 | class MockClient: 562 | def __init__(self, source): 563 | self._source = source 564 | self.issues = [] 565 | self.users = [] 566 | self.pull_request_ids = [] 567 | self.reset_stats() 568 | 569 | def reset_stats(self): 570 | self.issue_creates = 0 571 | self.issue_updates = 0 572 | self.comment_creates = 0 573 | self.comment_updates = 0 574 | self.comment_deletes = 0 575 | 576 | def get_user(self, username): 577 | try: 578 | return next(u for u in self.users if u.username == username) 579 | except StopIteration: 580 | raise Exception(f"Missing User with username {username}") 581 | 582 | def find_issues(self, min_updated_at=None): 583 | if min_updated_at: 584 | assert min_updated_at.tzinfo is not None 585 | 586 | for issue in self.issues: 587 | if min_updated_at is None or issue.updated_at >= min_updated_at: 588 | yield issue 589 | 590 | def find_other_issue(self, issue): 591 | if issue.source == Source.JIRA: 592 | if issue.metadata.github_repository and issue.metadata.github_issue_id: 593 | try: 594 | return next( 595 | i 596 | for i in self.issues 597 | if i.project == issue.metadata.github_repository 598 | and i.issue_id == issue.metadata.github_issue_id 599 | ) 600 | except StopIteration: 601 | return None 602 | else: 603 | return None 604 | else: 605 | try: 606 | return next( 607 | i 608 | for i in self.issues 609 | if i.metadata.github_repository == issue.project and i.metadata.github_issue_id == issue.issue_id 610 | ) 611 | except StopIteration: 612 | return None 613 | 614 | def get_issue(self, issue_id): 615 | try: 616 | return next(i for i in self.issues if i.issue_id == issue_id) 617 | except StopIteration: 618 | raise Exception(f"Missing Issue id {issue_id}") 619 | 620 | def create_issue(self, create_fields): 621 | fields = { 622 | "source": self._source, 623 | "is_bot": True, 624 | "issue_id": self._get_next_issue_id(), 625 | "project": self._get_project(), 626 | "created_at": now(), 627 | "updated_at": now(), 628 | "user": self._get_user(), 629 | "is_open": True, 630 | } 631 | 632 | for field_name in ["title", "is_open", "body", "priority", "issue_type", "metadata"]: 633 | if create_fields.get(field_name): 634 | fields[field_name] = create_fields[field_name] 635 | 636 | for field_name in ["labels", "milestones", "components"]: 637 | if create_fields.get(field_name): 638 | fields[field_name] = set(create_fields[field_name]) 639 | 640 | issue = Issue(**fields) 641 | self.issues.append(issue) 642 | 643 | self.issue_creates += 1 644 | 645 | return issue 646 | 647 | def update_issue(self, issue, update_fields): 648 | assert issue.source == self._source 649 | 650 | if ("title" in update_fields or "body" in update_fields) and not issue.is_bot: 651 | raise ValueError("Cannot update title or body of issue owned by another user") 652 | 653 | fields = issue.__dict__.copy() 654 | fields["updated_at"] = now() 655 | 656 | for field_name in ["title", "body", "priority", "issue_type", "metadata"]: 657 | if field_name in update_fields: 658 | if update_fields[field_name]: 659 | fields[field_name] = update_fields[field_name] 660 | else: 661 | fields.pop(field_name) 662 | 663 | for field_name in ["labels", "milestones", "components"]: 664 | if field_name in update_fields: 665 | if update_fields[field_name]: 666 | fields[field_name] = set(update_fields[field_name]) 667 | else: 668 | fields.pop(field_name) 669 | 670 | if "is_open" in update_fields: 671 | fields["is_open"] = update_fields["is_open"] 672 | 673 | updated_issue = Issue(**fields) 674 | self.issues = [i for i in self.issues if i.issue_id != issue.issue_id] 675 | self.issues.append(updated_issue) 676 | 677 | self.issue_updates += 1 678 | 679 | return updated_issue 680 | 681 | def create_comment(self, issue, create_fields): 682 | assert issue.source == self._source 683 | 684 | fields = { 685 | "source": self._source, 686 | "is_bot": True, 687 | "comment_id": self._get_next_comment_id(), 688 | "created_at": now(), 689 | "updated_at": now(), 690 | "user": self._get_user(), 691 | } 692 | 693 | for field_name in ["body", "metadata", "issue_metadata"]: 694 | if create_fields.get(field_name): 695 | fields[field_name] = create_fields[field_name] 696 | 697 | comment = Comment(**fields) 698 | issue.comments.append(comment) 699 | 700 | self.comment_creates += 1 701 | 702 | return comment 703 | 704 | def update_comment(self, comment, update_fields): 705 | assert comment.source == self._source 706 | 707 | if not comment.is_bot: 708 | raise ValueError("Cannot update comment owned by another user") 709 | 710 | issue = self._get_comment_issue(comment) 711 | 712 | fields = comment.__dict__.copy() 713 | fields["updated_at"] = now() 714 | 715 | for field_name in ["body", "metadata", "issue_metadata"]: 716 | if field_name in update_fields: 717 | if update_fields.get(field_name): 718 | fields[field_name] = update_fields[field_name] 719 | else: 720 | fields.pop(field_name) 721 | 722 | updated_comment = Comment(**fields) 723 | 724 | issue.comments.remove(comment) 725 | issue.comments.append(updated_comment) 726 | 727 | self.comment_updates += 1 728 | 729 | def delete_comment(self, comment): 730 | assert comment.source == self._source 731 | 732 | if not comment.is_bot: 733 | raise ValueError("Cannot delete comment owned by another user") 734 | 735 | self._get_comment_issue(comment).comments.remove(comment) 736 | 737 | self.comment_deletes += 1 738 | 739 | def is_issue(self, issue_id): 740 | if self._source == Source.JIRA: 741 | raise AttributeError("JIRA Client does not implement is_issue") 742 | 743 | return any(i for i in self.issues if i.issue_id == issue_id) 744 | 745 | def is_pull_request(self, pull_request_id): 746 | if self._source == Source.JIRA: 747 | raise AttributeError("JIRA Client does not implement is_pull_request") 748 | 749 | return pull_request_id in self.pull_request_ids 750 | 751 | def _get_comment_issue(self, comment): 752 | for issue in self.issues: 753 | if comment in issue.comments: 754 | return issue 755 | 756 | assert False 757 | 758 | def _get_user(self): 759 | if self._source == Source.JIRA: 760 | return User( 761 | source=Source.JIRA, 762 | username=constants.TEST_JIRA_USERNAME, 763 | display_name=constants.TEST_JIRA_USER_DISPLAY_NAME, 764 | ) 765 | else: 766 | return User( 767 | source=Source.GITHUB, 768 | username=constants.TEST_GITHUB_USER_LOGIN, 769 | display_name=constants.TEST_GITHUB_USER_NAME, 770 | ) 771 | 772 | def _get_next_issue_id(self): 773 | if self._source == Source.JIRA: 774 | return next_jira_issue_id() 775 | else: 776 | return next_github_issue_id() 777 | 778 | def _get_project(self): 779 | if self._source == Source.JIRA: 780 | return constants.TEST_JIRA_PROJECT_KEY 781 | else: 782 | return constants.TEST_GITHUB_REPOSITORY 783 | 784 | def _get_next_comment_id(self): 785 | return next_comment_id() 786 | -------------------------------------------------------------------------------- /tests/test_github.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from typing import Generator 4 | from datetime import datetime, timedelta, timezone 5 | 6 | from jirahub import github 7 | from jirahub.entities import Source, User, Metadata 8 | from jirahub.utils import UrlHelper 9 | 10 | from . import constants, mocks 11 | 12 | 13 | def test_get_token(): 14 | assert github.get_token() == constants.TEST_GITHUB_TOKEN 15 | 16 | 17 | class TestClient: 18 | @pytest.fixture 19 | def mock_repo(self): 20 | return mocks.MockGithub.repositories[0] 21 | 22 | @pytest.fixture 23 | def mock_github(self): 24 | return mocks.MockGithub() 25 | 26 | @pytest.fixture 27 | def client(self, config, mock_github): 28 | return github.Client(config, mock_github) 29 | 30 | def test_from_config(self, config): 31 | # Just "asserting" that there are no exceptions here. 32 | client = github.Client.from_config(config) 33 | assert isinstance(client, github.Client) 34 | 35 | def test_init(self, config, mock_github): 36 | # Just "asserting" that there are no exceptions here. 37 | github.Client(config, mock_github) 38 | 39 | def test_init_bad_credentials(self, config): 40 | with pytest.raises(Exception): 41 | github.Client(config, mocks.MockGithub(token="nope")) 42 | 43 | def test_init_missing_repo(self, config): 44 | config.github.repository = "testing/nope" 45 | with pytest.raises(Exception): 46 | github.Client(config, mocks.MockGithub()) 47 | 48 | def test_get_user(self, client, mock_github): 49 | mock_github.users.append(mocks.MockGithubUser("testusername123", "Test User 123")) 50 | mock_github.users.append(mocks.MockGithubUser("nodisplayname", None)) 51 | 52 | user = client.get_user("testusername123") 53 | assert user.username == "testusername123" 54 | assert user.display_name == "Test User 123" 55 | 56 | user = client.get_user("nodisplayname") 57 | assert user.username == "nodisplayname" 58 | assert user.display_name == "nodisplayname" 59 | 60 | with pytest.raises(Exception): 61 | client.get_user("nope") 62 | 63 | def test_find_issues(self, client, mock_repo): 64 | result = client.find_issues() 65 | assert isinstance(result, Generator) 66 | assert len(list(result)) == 0 67 | 68 | mock_repo.create_issue(title="Test issue") 69 | 70 | result = list(client.find_issues()) 71 | assert len(result) == 1 72 | 73 | def test_find_issues_min_updated_at(self, client, mock_repo): 74 | now = datetime.utcnow().replace(tzinfo=timezone.utc) 75 | 76 | result = client.find_issues(min_updated_at=now) 77 | assert isinstance(result, Generator) 78 | assert len(list(result)) == 0 79 | 80 | mock_repo.create_issue(title="Test issue") 81 | 82 | result = list(client.find_issues(min_updated_at=now + timedelta(seconds=1))) 83 | assert len(result) == 0 84 | 85 | result = list(client.find_issues(min_updated_at=now - timedelta(seconds=1))) 86 | assert len(result) == 1 87 | 88 | def test_find_other_issue(self, client, mock_repo, create_issue): 89 | jira_issue = create_issue(Source.JIRA) 90 | 91 | assert client.find_other_issue(jira_issue) is None 92 | 93 | raw_github_issue = mock_repo.create_issue(title="Test issue") 94 | jira_issue = create_issue( 95 | Source.JIRA, 96 | metadata=Metadata( 97 | github_repository=constants.TEST_GITHUB_REPOSITORY, github_issue_id=raw_github_issue.number 98 | ), 99 | ) 100 | 101 | result = client.find_other_issue(jira_issue) 102 | assert result.issue_id == raw_github_issue.number 103 | 104 | def test_get_issue(self, client, mock_repo): 105 | raw_issue = mock_repo.create_issue(title="Test issue") 106 | 107 | result = client.get_issue(raw_issue.number) 108 | 109 | assert result.issue_id == raw_issue.number 110 | 111 | def test_get_issue_missing(self, client): 112 | with pytest.raises(Exception): 113 | client.get_issue(12390) 114 | 115 | def test_create_issue_all_fields(self, client, mock_repo): 116 | result = client.create_issue( 117 | { 118 | "title": "Test issue", 119 | "body": "This is an issue body.", 120 | "labels": ["label1", "label2"], 121 | "milestones": [mock_repo.milestones[0].title], 122 | } 123 | ) 124 | 125 | assert result.title == "Test issue" 126 | assert result.body == "This is an issue body." 127 | assert result.labels == {"label1", "label2"} 128 | assert result.milestones == {mock_repo.milestones[0].title} 129 | 130 | assert len(mock_repo.issues) == 1 131 | raw_issue = mock_repo.issues[0] 132 | 133 | assert raw_issue.title == "Test issue" 134 | assert raw_issue.body.startswith("This is an issue body.") 135 | assert set([l.name for l in raw_issue.labels]) == {"label1", "label2"} 136 | assert raw_issue.milestone == mock_repo.milestones[0] 137 | 138 | def test_create_issue_minimum_fields(self, client, mock_repo): 139 | result = client.create_issue({"title": "Test issue"}) 140 | 141 | assert result.title == "Test issue" 142 | assert len(mock_repo.issues) == 1 143 | assert mock_repo.issues[0].title == "Test issue" 144 | 145 | def test_create_issue_custom_field(self, client, mock_repo): 146 | result = client.create_issue({"title": "Test issue", "assignee": "big.bird"}) 147 | 148 | assert result.title == "Test issue" 149 | assert len(mock_repo.issues) == 1 150 | assert mock_repo.issues[0].assignee == "big.bird" 151 | 152 | def test_create_issue_milestone_behavior(self, client, mock_repo): 153 | # Milestone that doesn't exist: 154 | client.create_issue({"title": "Test issue", "milestones": ["10.12.4"]}) 155 | 156 | assert len(mock_repo.issues) == 1 157 | assert mock_repo.issues[0].milestone is None 158 | 159 | mock_repo.issues = [] 160 | 161 | # One milestone that does exist, and one that does not: 162 | client.create_issue({"title": "Test issue", "milestones": ["10.12.4", mock_repo.milestones[0].title]}) 163 | 164 | assert len(mock_repo.issues) == 1 165 | assert mock_repo.issues[0].milestone == mock_repo.milestones[0] 166 | 167 | mock_repo.issues = [] 168 | 169 | # Multiple milestones exist: 170 | client.create_issue( 171 | { 172 | "title": "Test issue", 173 | "milestones": ["10.12.4", mock_repo.milestones[1].title, mock_repo.milestones[0].title], 174 | } 175 | ) 176 | 177 | assert len(mock_repo.issues) == 1 178 | assert mock_repo.issues[0].milestone == mock_repo.milestones[1] 179 | 180 | mock_repo.issues = [] 181 | 182 | # Update to a milestone that doesn't exist: 183 | issue = client.create_issue({"title": "Test issue"}) 184 | client.update_issue(issue, {"milestones": ["10.12.4"]}) 185 | 186 | assert len(mock_repo.issues) == 1 187 | assert mock_repo.issues[0].milestone is None 188 | 189 | def test_update_issue(self, client, mock_repo): 190 | raw_issue = mock_repo.create_issue(title="Test issue") 191 | 192 | issue = client.get_issue(raw_issue.number) 193 | 194 | client.update_issue(issue, {"title": "New title"}) 195 | 196 | assert raw_issue.title == "New title" 197 | 198 | def test_update_issue_other_user(self, client): 199 | issue = client.create_issue({"title": "Test issue"}) 200 | issue.raw_issue.user = mocks.MockGithubUser("somestranger", "Some Stranger") 201 | issue = client.get_issue(issue.issue_id) 202 | 203 | client.update_issue(issue, {"labels": ["fee", "fi", "fo", "fum"]}) 204 | issue = client.get_issue(issue.issue_id) 205 | assert issue.labels == {"fee", "fi", "fo", "fum"} 206 | 207 | with pytest.raises(Exception): 208 | client.update_issue(issue, {"title": "nope"}) 209 | 210 | with pytest.raises(Exception): 211 | client.update_issue(issue, {"body": "nope"}) 212 | 213 | def test_update_issue_wrong_source(self, client, create_issue): 214 | jira_issue = create_issue(Source.JIRA) 215 | 216 | with pytest.raises(AssertionError): 217 | client.update_issue(jira_issue, {"title": "nope"}) 218 | 219 | def test_issue_fields_round_trip(self, client, mock_repo): 220 | issue = client.create_issue( 221 | { 222 | "title": "Original title", 223 | "body": "Original body", 224 | "milestones": [mock_repo.milestones[0].title], 225 | "labels": ["originallabel1", "originallabel2"], 226 | } 227 | ) 228 | 229 | issue_id = issue.issue_id 230 | 231 | issue = client.get_issue(issue_id) 232 | 233 | assert issue.source == Source.GITHUB 234 | assert issue.title == "Original title" 235 | assert issue.body == "Original body" 236 | assert issue.milestones == {mock_repo.milestones[0].title} 237 | assert issue.labels == {"originallabel1", "originallabel2"} 238 | assert issue.is_bot is True 239 | assert issue.issue_id == issue_id 240 | assert issue.project == constants.TEST_GITHUB_REPOSITORY 241 | assert abs(datetime.utcnow().replace(tzinfo=timezone.utc) - issue.created_at) < timedelta(seconds=1) 242 | assert issue.created_at.tzinfo == timezone.utc 243 | assert abs(datetime.utcnow().replace(tzinfo=timezone.utc) - issue.created_at) < timedelta(seconds=1) 244 | assert issue.updated_at.tzinfo == timezone.utc 245 | assert issue.user.source == Source.GITHUB 246 | assert issue.user.username == constants.TEST_GITHUB_USER_LOGIN 247 | assert issue.user.display_name == constants.TEST_GITHUB_USER_NAME 248 | assert issue.is_open is True 249 | 250 | client.update_issue( 251 | issue, 252 | { 253 | "title": "Updated title", 254 | "body": "Updated body", 255 | "milestones": [mock_repo.milestones[1].title], 256 | "labels": ["updatedlabel1", "updatedlabel2"], 257 | "is_open": False, 258 | }, 259 | ) 260 | 261 | issue = client.get_issue(issue_id) 262 | 263 | assert issue.title == "Updated title" 264 | assert issue.body == "Updated body" 265 | assert issue.milestones == {mock_repo.milestones[1].title} 266 | assert issue.labels == {"updatedlabel1", "updatedlabel2"} 267 | assert issue.is_open is False 268 | 269 | client.update_issue(issue, {"is_open": True}) 270 | 271 | issue = client.get_issue(issue_id) 272 | 273 | assert issue.is_open is True 274 | 275 | client.update_issue(issue, {"body": None, "milestones": None, "labels": None}) 276 | 277 | issue = client.get_issue(issue_id) 278 | 279 | assert issue.title == "Updated title" 280 | assert issue.body == "" 281 | assert issue.milestones == set() 282 | assert issue.labels == set() 283 | 284 | def test_non_mirror_issue(self, client, mock_repo): 285 | raw_issue = mock_repo.create_issue(title="Test issue") 286 | raw_issue.user = mocks.MockGithubUser("somestranger", "Some Stranger") 287 | 288 | issue = client.get_issue(raw_issue.number) 289 | 290 | assert issue.is_bot is False 291 | 292 | def test_issue_with_comments(self, client, mock_repo): 293 | raw_issue = mock_repo.create_issue(title="Test issue") 294 | [raw_issue.create_comment(f"This is comment #{i+1}") for i in range(3)] 295 | 296 | issue = client.get_issue(raw_issue.number) 297 | 298 | assert len(issue.comments) == 3 299 | for i in range(3): 300 | assert issue.comments[i].body == f"This is comment #{i+1}" 301 | 302 | def test_create_comment(self, client, mock_repo): 303 | issue = client.create_issue({"title": "Issue title"}) 304 | 305 | client.create_comment(issue, {"body": "Comment body"}) 306 | 307 | raw_issue = mock_repo.issues[0] 308 | assert len(raw_issue.comments) == 1 309 | raw_comment = raw_issue.comments[0] 310 | 311 | assert raw_comment.body == "Comment body" 312 | 313 | def test_create_comment_wrong_source(self, client, create_issue): 314 | jira_issue = create_issue(Source.JIRA) 315 | 316 | with pytest.raises(AssertionError): 317 | client.create_comment(jira_issue, {"body": "nope"}) 318 | 319 | def test_update_comment(self, client, mock_repo): 320 | issue = client.create_issue({"title": "Issue title"}) 321 | comment = client.create_comment(issue, {"body": "Original comment body"}) 322 | 323 | client.update_comment(comment, {"body": "Updated comment body"}) 324 | 325 | assert mock_repo.issues[0].comments[0].body == "Updated comment body" 326 | 327 | def test_update_comment_other_user(self, client): 328 | issue = client.create_issue({"title": "Test issue"}) 329 | comment = client.create_comment(issue, {"body": "Comment body"}) 330 | comment.raw_comment.user = mocks.MockGithubUser("somestranger", "Some Stranger") 331 | comment = client.get_issue(issue.issue_id).comments[0] 332 | 333 | with pytest.raises(Exception): 334 | client.update_comment(comment, {"body": "nope"}) 335 | 336 | def test_update_comment_wrong_source(self, client, create_comment): 337 | jira_comment = create_comment(Source.JIRA) 338 | 339 | with pytest.raises(AssertionError): 340 | client.update_comment(jira_comment, {"body": "nope"}) 341 | 342 | def test_delete_comment(self, client, mock_repo): 343 | issue = client.create_issue({"title": "Issue title"}) 344 | comment = client.create_comment(issue, {"body": "Comment body"}) 345 | 346 | assert len(mock_repo.issues[0].comments) == 1 347 | 348 | client.delete_comment(comment) 349 | 350 | assert len(mock_repo.issues[0].comments) == 0 351 | 352 | def test_delete_comment_wrong_source(self, client, create_comment): 353 | jira_comment = create_comment(Source.JIRA) 354 | 355 | with pytest.raises(AssertionError): 356 | client.delete_comment(jira_comment) 357 | 358 | def test_delete_comment_wrong_user(self, client, create_comment): 359 | comment = create_comment(Source.GITHUB) 360 | 361 | with pytest.raises(ValueError): 362 | client.delete_comment(comment) 363 | 364 | def test_non_mirror_comment(self, client, mock_repo): 365 | raw_issue = mock_repo.create_issue(title="Test issue") 366 | raw_issue.comments.append( 367 | mocks.MockGithubComment( 368 | body="Test comment body", user=mocks.MockGithubUser("somestranger", "Some Stranger"), issue=raw_issue 369 | ) 370 | ) 371 | 372 | issue = client.get_issue(raw_issue.number) 373 | 374 | assert issue.comments[0].is_bot is False 375 | 376 | def test_comment_fields_round_trip(self, client): 377 | issue = client.create_issue({"title": "Issue title"}) 378 | comment = client.create_comment(issue, {"body": "Original comment body"}) 379 | 380 | issue_id = issue.issue_id 381 | 382 | comment = client.get_issue(issue_id).comments[0] 383 | 384 | assert comment.source == Source.GITHUB 385 | assert comment.is_bot is True 386 | assert comment.comment_id == comment.comment_id 387 | assert abs(datetime.utcnow().replace(tzinfo=timezone.utc) - comment.created_at) < timedelta(seconds=1) 388 | assert comment.created_at.tzinfo == timezone.utc 389 | assert abs(datetime.utcnow().replace(tzinfo=timezone.utc) - comment.created_at) < timedelta(seconds=1) 390 | assert comment.updated_at.tzinfo == timezone.utc 391 | assert comment.user.source == Source.GITHUB 392 | assert comment.user.username == constants.TEST_GITHUB_USER_LOGIN 393 | assert comment.user.display_name == constants.TEST_GITHUB_USER_NAME 394 | assert comment.body == "Original comment body" 395 | 396 | client.update_comment(comment, {"body": "Updated comment body"}) 397 | 398 | comment = client.get_issue(issue_id).comments[0] 399 | 400 | assert comment.body == "Updated comment body" 401 | 402 | def test_is_issue(self, client, mock_repo): 403 | raw_issue = mock_repo.create_issue(title="Test issue") 404 | raw_pull = mocks.MockGithubPull(title="Test PR") 405 | mock_repo.pulls.append(raw_pull) 406 | 407 | assert client.is_issue(raw_issue.number) is True 408 | assert client.is_issue(raw_pull.number) is False 409 | assert client.is_issue(12512512) is False 410 | 411 | def test_is_pull_request(self, client, mock_repo): 412 | raw_issue = mock_repo.create_issue(title="Test issue") 413 | raw_pull = mocks.MockGithubPull(title="Test PR") 414 | mock_repo.pulls.append(raw_pull) 415 | 416 | assert client.is_pull_request(raw_issue.number) is False 417 | assert client.is_pull_request(raw_pull.number) is True 418 | assert client.is_pull_request(12512512) is False 419 | 420 | 421 | class TestFormatter: 422 | @pytest.fixture 423 | def formatter(self, config, jira_client): 424 | url_helper = UrlHelper.from_config(config) 425 | 426 | return github.Formatter(config, url_helper, jira_client) 427 | 428 | @pytest.mark.parametrize( 429 | "url, link_text, expected", 430 | [ 431 | ("https://www.example.com", None, ""), 432 | ("https://www.example.com", "link text", "[link text](https://www.example.com)"), 433 | ( 434 | "https://github.com/testing/test-repo/blob/stable/.gitignore#L13", 435 | None, 436 | "", 437 | ), 438 | ("https://github.com/testing/test-repo/issues/2", None, "#2"), 439 | ( 440 | "https://github.com/testing/test-repo/issues/2", 441 | "link text", 442 | "[link text](https://github.com/testing/test-repo/issues/2)", 443 | ), 444 | ("https://github.com/testing/other-test-repo/issues/5", None, "testing/other-test-repo#5"), 445 | ( 446 | "https://github.com/testing/other-test-repo/issues/5", 447 | "link text", 448 | "[link text](https://github.com/testing/other-test-repo/issues/5)", 449 | ), 450 | ("https://github.com/testing/test-repo/pull/6", None, "#6"), 451 | ( 452 | "https://github.com/testing/test-repo/pull/6", 453 | "link text", 454 | "[link text](https://github.com/testing/test-repo/pull/6)", 455 | ), 456 | ("https://github.com/testing/other-test-repo/pull/4", None, "testing/other-test-repo#4"), 457 | ( 458 | "https://github.com/testing/other-test-repo/pull/4", 459 | "link text", 460 | "[link text](https://github.com/testing/other-test-repo/pull/4)", 461 | ), 462 | ("https://github.com/username123", None, "@username123"), 463 | ("https://github.com/username123", "link text", "[link text](https://github.com/username123)"), 464 | ], 465 | ) 466 | def test_format_link(self, formatter, url, link_text, expected): 467 | assert formatter.format_link(url=url, link_text=link_text) == expected 468 | 469 | @pytest.mark.parametrize( 470 | "body, expected", 471 | [ 472 | ("This is a body without formatting.", "This is a body without formatting."), 473 | ( 474 | "This is a body with a unadorned URL: https://www.example.com", 475 | "This is a body with a unadorned URL: ", 476 | ), 477 | ( 478 | "This is a body with a JIRA formatted link, but no link text: [https://www.example.com]", 479 | "This is a body with a JIRA formatted link, but no link text: ", 480 | ), 481 | ( 482 | "This is a body with a [JIRA formatted external link|https://www.example.com].", 483 | "This is a body with a [JIRA formatted external link](https://www.example.com).", 484 | ), 485 | ( 486 | "This is a body with a [JIRA formatted GitHub issue link|https://github.com/testing/test-repo/issues/2]", 487 | "This is a body with a [JIRA formatted GitHub issue link](https://github.com/testing/test-repo/issues/2)", 488 | ), 489 | ( 490 | "This is a body with a JIRA mention: [~username123]", 491 | "This is a body with a JIRA mention: [Test User 123](https://test.jira.server/secure/ViewProfile.jspa?name=username123)", 492 | ), 493 | ( 494 | "This is a body with a JIRA mention that doesn't exist: [~missing]", 495 | "This is a body with a JIRA mention that doesn't exist: [missing](https://test.jira.server/secure/ViewProfile.jspa?name=missing)", 496 | ), 497 | ("This body is #1!", "This body is #⁠1!"), 498 | ( 499 | "This is a link to GitHub code: https://github.com/testing/test-repo/blob/stable/.gitignore#L13", 500 | "This is a link to GitHub code: ", 501 | ), 502 | ( 503 | "This is a link to a GitHub issue in the same repo: https://github.com/testing/test-repo/issues/2", 504 | "This is a link to a GitHub issue in the same repo: #2", 505 | ), 506 | ( 507 | "This is a link to a GitHub issue in the same repo: [https://github.com/testing/test-repo/issues/2]", 508 | "This is a link to a GitHub issue in the same repo: #2", 509 | ), 510 | ( 511 | "This is a link to a GitHub issue in another repo: https://github.com/testing/other-test-repo/issues/5", 512 | "This is a link to a GitHub issue in another repo: testing/other-test-repo#5", 513 | ), 514 | ( 515 | "This is a link to a GitHub issue in another repo: [https://github.com/testing/other-test-repo/issues/5]", 516 | "This is a link to a GitHub issue in another repo: testing/other-test-repo#5", 517 | ), 518 | ( 519 | "This is a link to a GitHub PR in the same repo: https://github.com/testing/test-repo/pull/4", 520 | "This is a link to a GitHub PR in the same repo: #4", 521 | ), 522 | ( 523 | "This is a link to a GitHub PR in the same repo: [https://github.com/testing/test-repo/pull/4]", 524 | "This is a link to a GitHub PR in the same repo: #4", 525 | ), 526 | ( 527 | "This is a link to a GitHub PR in another repo: https://github.com/testing/other-test-repo/pull/6", 528 | "This is a link to a GitHub PR in another repo: testing/other-test-repo#6", 529 | ), 530 | ( 531 | "This is a link to a GitHub PR in another repo: [https://github.com/testing/other-test-repo/pull/6]", 532 | "This is a link to a GitHub PR in another repo: testing/other-test-repo#6", 533 | ), 534 | ( 535 | "This is a link to a GitHub user profile: https://github.com/username123", 536 | "This is a link to a GitHub user profile: @username123", 537 | ), 538 | ( 539 | "This is a link to a GitHub user profile: [https://github.com/username123]", 540 | "This is a link to a GitHub user profile: @username123", 541 | ), 542 | ( 543 | "This looks like a GitHub user mention but should be escaped: @username123", 544 | "This looks like a GitHub user mention but should be escaped: @\u2063username123", 545 | ), 546 | ( 547 | "This is a body with *bold*, _italic_, and {{monospaced}} text.", 548 | "This is a body with **bold**, *italic*, and `monospaced` text.", 549 | ), 550 | ( 551 | "This is a body with -deleted-, +inserted+, ^superscript^, and ~subscript~ text.", 552 | "This is a body with ~~deleted~~, inserted, superscript, and subscript text.", 553 | ), 554 | ( 555 | "This is a body with a {color:red}color tag{color} which is unsupported by GitHub.", 556 | "This is a body with a color tag which is unsupported by GitHub.", 557 | ), 558 | ( 559 | """This body has a block of Python code in it: 560 | {code:python} 561 | import sys 562 | sys.stdout.write("Hello, world!") 563 | sys.stdout.write("\n") 564 | {code}""", 565 | """This body has a block of Python code in it: 566 | ```python 567 | import sys 568 | sys.stdout.write("Hello, world!") 569 | sys.stdout.write("\n") 570 | ```""", 571 | ), 572 | ( 573 | """This body has a block of generic code in it: 574 | {code} 575 | import sys 576 | sys.stdout.write("Hello, world!") 577 | sys.stdout.write("\n") 578 | {code}""", 579 | """This body has a block of generic code in it: 580 | ``` 581 | import sys 582 | sys.stdout.write("Hello, world!") 583 | sys.stdout.write("\n") 584 | ```""", 585 | ), 586 | ( 587 | """This body has a preformatted block of text in it: 588 | {noformat} 589 | Ain't no formatting *here*! 590 | {noformat}""", 591 | """This body has a preformatted block of text in it: 592 | ``` 593 | Ain't no formatting *here*! 594 | ```""", 595 | ), 596 | ( 597 | """This body has a quote block in it: 598 | {quote} 599 | Quotes are great! 600 | Turns out there *is* formatting in here! 601 | {quote}""", 602 | """This body has a quote block in it: 603 | 604 | > Quotes are great! 605 | > Turns out there **is** formatting in here! 606 | """, 607 | ), 608 | ("This body has empty quoted content: {quote}{quote}", "This body has empty quoted content: "), 609 | ("This body has empty code content: {code}{code}", "This body has empty code content: "), 610 | ("This body has empty noformat content: {noformat}{noformat}", "This body has empty noformat content: "), 611 | ("h1. What a heading", "# What a heading"), 612 | ("h2. What a heading", "## What a heading"), 613 | ("h3. What a heading", "### What a heading"), 614 | ("h4. What a heading", "#### What a heading"), 615 | ("h5. What a heading", "##### What a heading"), 616 | ("h6. What a heading", "###### What a heading"), 617 | ], 618 | ) 619 | def test_format_body(self, formatter, body, expected, jira_client): 620 | jira_client.users.append(User(Source.JIRA, "username123", "Test User 123")) 621 | 622 | assert formatter.format_body(body) == expected 623 | --------------------------------------------------------------------------------