Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ Sync2Jira is a service that listens to activity on upstream GitHub repositories
### Prerequisites

- Python 3.12+
- API access (via Personal Access Token) to a Jira Data Center instance
- JIRA API access using one of:
- **PAT (Personal Access Token / API token)**: email + API token (e.g. Jira Cloud)
- **OAuth 2.0 (2LO)**: Atlassian service account client ID and secret
- GitHub API token
- Fedora messaging environment (for production)

Expand Down Expand Up @@ -89,7 +91,15 @@ config = {
'server': 'https://your-jira.example.com',
'verify': True,
},
'token_auth': 'your_jira_token',
# Use 'pat' (default) or 'oauth2'
'auth_method': 'pat',
# For OAuth 2.0: set auth_method to 'oauth2' and uncomment:
# 'oauth2': {
# 'client_id': 'YOUR_CLIENT_ID',
# 'client_secret': 'YOUR_CLIENT_SECRET',
# },
# For PAT: email and API token (e.g. Jira Cloud)
'basic_auth': ('your-email@example.com', 'YOUR_JIRA_API_TOKEN'),
},
},

Expand Down Expand Up @@ -125,13 +135,15 @@ config = {
| Option | Description | Default |
|--------|-------------|---------|
| `github_token` | GitHub API token for authentication | Required |
| `jira` | JIRA instance configurations | Required |
| `jira` | JIRA instance configs (per-instance: PAT via `basic_auth` or OAuth 2.0 via `oauth2`) | Required |
| `map` | Repository-to-project mappings | Required |
| `testing` | Enable dry-run mode (no actual changes) | `False` |
| `initialize` | Sync all existing issues on startup | `False` |
| `filters` | Filter issues by labels, milestones, etc. | `{}` |
| `admins` | List of admin users for notifications | `[]` |

**JIRA authentication:** Each JIRA instance can use **PAT** (``auth_method: 'pat'``, with ``basic_auth``: email + API token) or **OAuth 2.0** (``auth_method: 'oauth2'``, with ``oauth2``: ``client_id`` and ``client_secret`` from an Atlassian service account). See the `Configuration Reference <https://sync2jira.readthedocs.io/en/main/config-file.html>`_ for details.

## Usage

### Sync2Jira Service
Expand Down
38 changes: 36 additions & 2 deletions docs/source/config-file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,46 @@ The config file is made up of multiple parts
'server': 'https://some_jira_server_somewhere.com',
'verify': True,
},
'token_auth': 'YOUR_API_TOKEN',
'basic_auth': ('your-email@example.com', 'YOUR_API_TOKEN'),
},
},

* Here you can configure multiple JIRA instances if you have projects with differing downstream JIRA instances.
Ensure to name them appropriately, in name of the JIRA instance above is `example`.
Ensure to name them appropriately; in the example above the JIRA instance name is `example`.

* You can choose authentication method per instance with ``auth_method``:

* **PAT (Personal Access Token)** – default. Set ``auth_method: 'pat'`` and use
``basic_auth`` (email and API token, e.g. for Jira Cloud):

.. code-block:: python

'example': {
'options': {'server': 'https://jira.example.com', 'verify': True},
'auth_method': 'pat',
'basic_auth': (
'email',
'API Token'
),
},

* **OAuth 2.0 (2LO, service account)** – Set ``auth_method: 'oauth2'`` and provide
``oauth2`` with Atlassian client ID and secret (e.g. from a service account):

.. code-block:: python

'example': {
'options': {'server': 'https://your-domain.atlassian.net', 'verify': True},
'auth_method': 'oauth2',
'oauth2': {
'client_id': 'YOUR_CLIENT_ID',
'client_secret': 'YOUR_CLIENT_SECRET',
# optional: 'token_url': 'https://auth.atlassian.com/oauth/token',
},
},

If ``auth_method`` is omitted, **PAT is assumed**. To use OAuth 2.0, set
``auth_method`` to ``'oauth2'`` and provide the ``oauth2`` block.

.. code-block:: python

Expand Down
27 changes: 23 additions & 4 deletions docs/source/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,38 @@ Want to quickly get started working with Sync2Jira? Follow these steps:

'github_token': 'YOUR_TOKEN',

3. Enter relevant JIRA information
3. Enter relevant JIRA information. **PAT (default):** use ``basic_auth``. **OAuth 2.0:**
set ``auth_method`` to ``'oauth2'`` and provide ``oauth2.client_id`` and
``oauth2.client_secret`` (see :doc:`config-file` for full options).

.. code-block:: python

'default_jira_instance': 'example',
# This should be the username of the account corresponding with `token_auth` below.
'jira_username': 'your-bot-account',
'jira_username': 'your-bot-account', # optional, for duplicate detection
'jira': {
'example': {
'options': {
'server': 'https://some_jira_server_somewhere.com',
'verify': True,
},
'token_auth': 'YOUR_TOKEN',
'auth_method': 'pat',
'basic_auth': ('your-email@example.com', 'YOUR_API_TOKEN'),
},
},

For OAuth 2.0 (e.g. Atlassian service account), use instead:

.. code-block:: python

'example': {
'options': {
'server': 'https://your-domain.atlassian.net',
'verify': True,
},
'auth_method': 'oauth2',
'oauth2': {
'client_id': 'YOUR_CLIENT_ID',
'client_secret': 'YOUR_CLIENT_SECRET',
},
},

Expand Down
10 changes: 9 additions & 1 deletion fedmsg.d/sync2jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@
'server': 'https://some_jira_server_somewhere.com',
'verify': True,
},
'token_auth': 'YOUR_JIRA_ACCESS_TOKEN',
# Use 'pat' (default) or 'oauth2'
'auth_method': 'pat',
# For OAuth 2.0: set auth_method to 'oauth2' and use:
# 'oauth2': {
# 'client_id': 'YOUR_CLIENT_ID',
# 'client_secret': 'YOUR_CLIENT_SECRET',
# },
# For PAT: email and API token (e.g. Jira Cloud)
'basic_auth': ('your-email@example.com', 'YOUR_JIRA_API_TOKEN'),
},
},
'default_jira_fields': {
Expand Down
21 changes: 14 additions & 7 deletions sync2jira/downstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@

import Rover_Lookup
from sync2jira.intermediary import Issue, PR
from sync2jira.jira_auth import build_jira_client_kwargs
from sync2jira.jira_auth import (
build_jira_client_kwargs,
invalidate_oauth2_cache_for_config,
)

load_dotenv()
# The date the service was upgraded
Expand Down Expand Up @@ -263,12 +266,14 @@ def _comment_format_legacy(comment):
)


def get_jira_client(issue, config):
def get_jira_client(issue, config, invalidate_oauth2_cache=False):
"""
Function to match and create JIRA client.

:param sync2jira.intermediary.Issue issue: Issue object
:param dict config: Config dict
:param bool invalidate_oauth2_cache: If True, clear OAuth2 token cache for this
instance before building the client (e.g. after a JIRAError on retry).
:returns: Matching JIRA client
:rtype: jira.client.JIRA
"""
Expand All @@ -291,7 +296,10 @@ def get_jira_client(issue, config):
log.error("No jira_instance for issue and there is no default in the config")
raise Exception("No configured jira_instance for issue")

client_kwargs = build_jira_client_kwargs(config["sync2jira"]["jira"][jira_instance])
jira_instance_config = config["sync2jira"]["jira"][jira_instance]
if invalidate_oauth2_cache:
invalidate_oauth2_cache_for_config(jira_instance_config)
client_kwargs = build_jira_client_kwargs(jira_instance_config)
client = jira.client.JIRA(**client_kwargs)
client.session() # This raises an exception if authentication was not successful
return client
Expand Down Expand Up @@ -1401,11 +1409,10 @@ def sync_with_jira(issue, config):
if retry:
log.info("[Issue] Jira retry failed; aborting")
raise

# The error is probably because our access has expired; refresh it
# and try again.
# The error may be due to expired/revoked auth. Ask get_jira_client to
# invalidate OAuth2 cache so the next call fetches a new token (no-op for PAT).
log.info("[Issue] Jira request failed; refreshing the Jira client")
client = get_jira_client(issue, config)
client = get_jira_client(issue, config, invalidate_oauth2_cache=True)

# Retry the update
retry = True
Expand Down
6 changes: 3 additions & 3 deletions sync2jira/downstream_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,10 @@ def sync_with_jira(pr, config):
log.info("[PR] Jira retry failed; aborting")
raise

# The error is probably because our access has expired; refresh it
# and try again.
# The error may be due to expired/revoked auth. Ask get_jira_client to
# invalidate OAuth2 cache so the next call fetches a new token (no-op for PAT).
log.info("[PR] Jira request failed; refreshing the Jira client")
client = d_issue.get_jira_client(pr, config)
client = d_issue.get_jira_client(pr, config, invalidate_oauth2_cache=True)

# Retry the update
retry = True
Expand Down
20 changes: 20 additions & 0 deletions sync2jira/jira_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,23 @@ def build_jira_client_kwargs(jira_instance_config: Dict[str, Any]) -> Dict[str,
raise ValueError(
f"Unsupported auth_method: {auth_method!r}. Use {AUTH_METHOD_PAT!r} or {AUTH_METHOD_OAUTH2!r}"
)


def invalidate_oauth2_cache_for_config(jira_instance_config: Dict[str, Any]):
"""
Invalidate the OAuth2 token cache for the given Jira instance config.

If the config has oauth2 credentials, the cached token (if any) is removed so
the next client build will request a new token. Use this when Jira has
rejected a request (e.g. JIRAError) so a retry does not reuse a revoked or
invalid token.
"""
oauth2_cfg = jira_instance_config.get("oauth2")
if not oauth2_cfg or not isinstance(oauth2_cfg, dict):
return
client_id = oauth2_cfg.get("client_id")
client_secret = oauth2_cfg.get("client_secret")
token_url = oauth2_cfg.get("token_url", DEFAULT_OAUTH2_TOKEN_URL)
key = (client_id, client_secret, token_url)
_oauth2_token_cache.pop(key, None)
log.debug("Invalidated OAuth2 token cache for Jira instance")
71 changes: 61 additions & 10 deletions tests/test_downstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,18 +135,40 @@ def test_get_jira_client_not_issue(self, mock_client):
mock_client.assert_not_called()

@mock.patch("jira.client.JIRA")
def test_get_jira_client_not_instance(self, mock_client):
def test_get_jira_client_no_instance(self, mock_client):
"""
This tests 'get_jira_client' function there is no JIRA instance
This tests 'get_jira_client' function when there is no JIRA instance
"""
# Set up return values
self.mock_issue.downstream = {}

# Call the function
with self.assertRaises(Exception):
d.get_jira_client(issue=self.mock_issue, config=self.mock_config)

# Assert everything was called correctly
config_no_default = {
"sync2jira": {
**self.mock_config["sync2jira"],
"default_jira_instance": None,
},
}
mock_issue = Issue(
source="github",
title="t",
url="https://example.com/1",
upstream="owner/repo",
comments=[],
config=config_no_default,
tags=[],
fixVersion=[],
priority=None,
content="",
reporter={},
assignee=[],
status="Open",
id_="1",
storypoints=None,
upstream_id="1",
issue_type=None,
downstream={"project": "dummy"},
)
with self.assertRaises(Exception) as cm:
d.get_jira_client(issue=mock_issue, config=config_no_default)

self.assertEqual(str(cm.exception), "No configured jira_instance for issue")
mock_client.assert_not_called()

@mock.patch("jira.client.JIRA")
Expand Down Expand Up @@ -194,6 +216,35 @@ def test_get_jira_client_auth_failure(self, mock_client):
)
mock_jira_instance.session.assert_called_once()

@mock.patch(PATH + "invalidate_oauth2_cache_for_config")
@mock.patch("jira.client.JIRA")
def test_get_jira_client_invalidate_oauth2_cache(
self, mock_jira_class, mock_invalidate
):
"""
get_jira_client(..., invalidate_oauth2_cache=True) calls
invalidate_oauth2_cache_for_config with the jira instance config before
building the client.
"""
mock_issue = MagicMock(spec=Issue)
mock_issue.downstream = {"jira_instance": "mock_jira_instance"}
mock_jira_instance = MagicMock()
mock_jira_instance.session.return_value = None
mock_jira_class.return_value = mock_jira_instance

response = d.get_jira_client(
issue=mock_issue,
config=self.mock_config,
invalidate_oauth2_cache=True,
)

expected_config = self.mock_config["sync2jira"]["jira"]["mock_jira_instance"]
mock_invalidate.assert_called_once_with(expected_config)
mock_jira_class.assert_called_once_with(
basic_auth=("email", "token"), options={"server": "mock_server"}
)
self.assertEqual(response, mock_jira_instance)

@mock.patch("jira.client.JIRA")
def test_get_existing_legacy(self, client):
"""
Expand Down
45 changes: 44 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import requests

import sync2jira.jira_auth as jira_auth_module
from sync2jira.jira_auth import build_jira_client_kwargs
from sync2jira.jira_auth import (
build_jira_client_kwargs,
invalidate_oauth2_cache_for_config,
)
import sync2jira.main as m

PATH = "sync2jira.main."
Expand Down Expand Up @@ -454,6 +457,17 @@ def make_response(access_token, expires_in=60):
self.assertEqual(kwargs["token_auth"], "refreshed_token")
self.assertEqual(mock_post.call_count, 2)

def test_jira_auth_oauth2_not_dict(self):
"""OAuth2 config value that is not a dict raises ValueError."""
config = {
"options": {"server": "https://site.atlassian.net"},
"auth_method": "oauth2",
"oauth2": "invalid", # not a dict, so branch around token fetch is taken
}
with self.assertRaises(ValueError) as ctx:
build_jira_client_kwargs(config)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this test supposed to be targeting invalidate_oauth2_cache_for_config() rather than build_jira_client_kwargs()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it is not raising any exception to be checked just normal return and also in the following test case in this line we are checking the calling of invalidate_oauth2_cache_for_config() with invalid oauth2. We don't have exception to check so we are asserting that we are calling that function.

https://github.com/release-engineering/Sync2Jira/pull/432/changes#diff-b2c0f23b6e1e250652284cfceedbdb02d7e8af1b0ba5bbb7f83d79bd56a61f0bR242

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally, when we give a function bad inputs, it reports an error, and so we devise a test which supplies such inputs and ensures that the error occurs. However, in this case, the CUT is intended to report no errors when we give it bad inputs, and so we should have a test which ensures that no exception is raised when the bad inputs are supplied. And, as you've observed, such a test would not include any explicit assertions, because none are required (and, in fact, none can be made); it would rely instead on the test framework reporting a failure if any error were "unexpectedly" raised, because, that, in fact, would be the case.

self.assertIn("oauth2 must be a dict", str(ctx.exception))

def test_jira_auth_oauth2_missing_credentials(self):
"""OAuth2 missing client_id or client_secret raises ValueError."""
base = {
Expand Down Expand Up @@ -482,3 +496,32 @@ def test_jira_auth_oauth2_request_failure(self, mock_post):
with self.assertRaises(requests.RequestException) as ctx:
build_jira_client_kwargs(config)
self.assertIn("network error", str(ctx.exception))

@patch("sync2jira.jira_auth.requests.post")
def test_jira_auth_invalidate_oauth2_clears_cache(self, mock_post):
"""invalidate_oauth2_cache_for_config clears OAuth2 cache; next build fetches new token."""

def make_response(access_token):
return MagicMock(
status_code=200,
json=lambda t=access_token: {"access_token": t, "expires_in": 3600},
raise_for_status=MagicMock(),
)

mock_post.side_effect = [
make_response("cached_token"),
make_response("new_token_after_invalidate"),
]
config = {
"options": {"server": "https://site.atlassian.net"},
"auth_method": "oauth2",
"oauth2": {"client_id": "cid", "client_secret": "csecret"},
}
kwargs1 = build_jira_client_kwargs(config)
self.assertEqual(kwargs1["token_auth"], "cached_token")
kwargs2 = build_jira_client_kwargs(config)
self.assertEqual(kwargs2["token_auth"], "cached_token")
invalidate_oauth2_cache_for_config(config)
kwargs3 = build_jira_client_kwargs(config)
self.assertEqual(kwargs3["token_auth"], "new_token_after_invalidate")
self.assertEqual(mock_post.call_count, 2)