├── 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|^)(http.*?)>?(\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 |
--------------------------------------------------------------------------------