Skip to content
Merged
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
62 changes: 41 additions & 21 deletions sync2jira/downstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import operator
import os
import re
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Optional, Tuple, Union
import unicodedata

from dotenv import load_dotenv
Expand Down Expand Up @@ -316,22 +316,34 @@ def get_existing_jira_issue(client, issue, config):
:rtype: JIssue or None
"""

# Fetch the Jira issue objects using the key list.
jql = _get_existing_jira_issue_query(issue)
issue_keys, jql = _get_existing_jira_issue_query(issue)
if not jql:
return None
results: ResultList[JIssue] = client.search_issues(jql)
Comment thread
webbnh marked this conversation as resolved.
if not results:
# It is _possible_ that the downstream issue exists (i.e., we did hit
# it in the cache or in Snowflake) but the Jira search cannot find it.
# (This happens when the issue has been archived.) Fail as gracefully
# as we can here, but our caller will probably create it again.
log.warning(
"Previously-existing downstream issue %s not found for upstream issue %s.",
issue.id,
issue.url,
)
return None
# JQL/search index can lag right after create, or hide archived issues.
# Resolve by issue key (direct GET) before giving up and duplicating.
results = ResultList[JIssue]()
Comment thread
webbnh marked this conversation as resolved.
for key in issue_keys:
try:
found = client.issue(key)
except JIRAError:
continue
log.info(
"JQL missed key %s for upstream %s; using direct issue fetch.",
key,
issue.url,
)
results = ResultList[JIssue]((found,))
break
if not results:
Comment thread
webbnh marked this conversation as resolved.
log.warning(
"Downstream issue not found for upstream %s after JQL %r and direct fetch for keys %s.",
issue.url,
jql,
issue_keys,
)
return None

# If there is more than one issue, remove duplicates and filter the list
# down to one.
Expand All @@ -358,25 +370,27 @@ def get_existing_jira_issue(client, issue, config):
return results[0]


def _get_existing_jira_issue_query(issue: Issue) -> Optional[str]:
def _get_existing_jira_issue_query(
issue: Issue,
) -> Tuple[tuple[str, ...], Optional[str]]:
"""
Generate a JQL query to find downstream issues corresponding to a given
upstream issue. Return None if no matches were found in either our local
upstream issue. Return empty tuple and None if no matches were found in either our local
cache or in the Dataverse.

:param sync2jira.intermediary.Issue issue: Issue object
:returns: A string containing the JQL query or None if no matches
:rtype: str or None
:rtype: Tuple[tuple[str, ...], Optional[str]]
"""
if result := jira_cache.get(issue.url):
issue_keys = (result,)
else:
results = execute_snowflake_query(issue)
if not results:
return None
issue_keys = (row[0] for row in results)
return (), None
issue_keys = tuple(row[0] for row in results)

return f"key in ({','.join(issue_keys)})"
return issue_keys, f"key in ({','.join(issue_keys)})"


def _filter_downstream_issues(
Expand Down Expand Up @@ -445,7 +459,11 @@ def check_comments_for_duplicate(client, result, username):
"""
for comment in client.comments(result):
search = re.search(r"Marking as duplicate of (\w*)-(\d*)", comment.body)
if search and comment.author.name == username:
author = comment.author
author_label = getattr(author, "displayName", None) or getattr(
author, "name", None
)
Comment thread
webbnh marked this conversation as resolved.
if search and author_label == username:
issue_id = search.groups()[0] + "-" + search.groups()[1]
return client.issue(issue_id)
return None
Expand Down Expand Up @@ -1139,7 +1157,9 @@ def _update_assignee(client, existing, issue, overwrite):
else:
# Without an upstream owner, update only if the downstream is not
# assigned to the project owner.
update = issue.downstream.get("owner") != existing.fields.assignee.name
update = (
issue.downstream.get("owner") != existing.fields.assignee.displayName
)
Comment thread
webbnh marked this conversation as resolved.
else:
# We're not overwriting, so call assign_user() only if the downstream
# doesn't already have an assignment.
Expand Down
52 changes: 39 additions & 13 deletions tests/test_downstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,55 +287,78 @@ def test_get_existing_newstyle(self, mock_client, mock_get_query, mock_filter):
mock_issue_3.fields = MagicMock()
mock_issue_3.fields.updated = "2025-11-30T00:00:00.0+0000"

def _q(keys):
Comment thread
webbnh marked this conversation as resolved.
if not keys:
return (), None
return keys, f"key in ({','.join(keys)})"

scenarios = (
{
"scenario": "_get_existing_jira_issue_query returns None",
"jira_results": None,
"scenario": "_get_existing_jira_issue_query returns no keys",
"query_return": _q(()),
"search_issues": None,
"filter_results": None,
"expected": None,
"issue_side_effect": None,
},
{
"scenario": "Jira search returns no items",
"jira_results": "mock issue key query string",
"scenario": "Jira search returns no items, direct fetch fails",
"query_return": _q(("MOCK-1",)),
"search_issues": ResultList[JIssue](()),
"filter_results": None,
"expected": None,
"issue_side_effect": JIRAError(),
},
{
"scenario": "Jira search returns no items, direct fetch succeeds",
"query_return": _q(("MOCK-1",)),
"search_issues": ResultList[JIssue](()),
"filter_results": None,
"expected": mock_issue_1,
"issue_side_effect": None,
},
{
"scenario": "Jira search returns one item",
"jira_results": "mock issue key query string",
"query_return": _q(("MOCK-1",)),
"search_issues": ResultList[JIssue]((mock_issue_1,)),
"filter_results": None,
"expected": mock_issue_1,
"issue_side_effect": None,
},
{
"scenario": "_filter_downstream_issues returns one item",
"jira_results": "mock issue key query string",
"query_return": _q(("MOCK-1", "MOCK-2", "MOCK-3")),
"search_issues": ResultList[JIssue](
(mock_issue_1, mock_issue_2, mock_issue_3)
),
"filter_results": ResultList[JIssue]((mock_issue_3,)),
"expected": mock_issue_3,
"issue_side_effect": None,
},
{
"scenario": "_filter_downstream_issues returns multiple items",
"jira_results": "mock issue key query string",
"query_return": _q(("MOCK-1", "MOCK-2", "MOCK-3")),
"search_issues": ResultList[JIssue](
(mock_issue_1, mock_issue_2, mock_issue_3)
),
"filter_results": ResultList[JIssue](
(mock_issue_1, mock_issue_2, mock_issue_3)
),
"expected": mock_issue_2, # Most-recently updated
"issue_side_effect": None,
},
)

for x in scenarios:
d.jira_cache = d.UrlCache() # Clear the cache
mock_get_query.return_value = x["jira_results"]
mock_get_query.return_value = x["query_return"]
mock_client.search_issues.return_value = x["search_issues"]
mock_filter.return_value = x["filter_results"]
if x["issue_side_effect"] is not None:
mock_client.issue.side_effect = x["issue_side_effect"]
else:
mock_client.issue.side_effect = None
mock_client.issue.return_value = mock_issue_1
Comment thread
webbnh marked this conversation as resolved.
result = d.get_existing_jira_issue(
client=mock_client, issue=self.mock_issue, config=self.mock_config
)
Expand All @@ -349,17 +372,17 @@ def test_get_existing_jira_issue_query(self, mock_snowflake):
{
"jira_cache": {self.mock_issue.url: "issue_key"},
"snowflake": (),
"expected": "key in (issue_key)",
"expected": (("issue_key",), "key in (issue_key)"),
},
{
"jira_cache": {},
"snowflake": (),
"expected": None,
"expected": ((), None),
},
{
"jira_cache": {},
"snowflake": (("issue_key",),),
"expected": "key in (issue_key)",
"expected": (("issue_key",), "key in (issue_key)"),
},
{
"jira_cache": {},
Expand All @@ -368,7 +391,10 @@ def test_get_existing_jira_issue_query(self, mock_snowflake):
("issue_key_2",),
("issue_key_3",),
),
"expected": "key in (issue_key_1,issue_key_2,issue_key_3)",
"expected": (
("issue_key_1", "issue_key_2", "issue_key_3"),
"key in (issue_key_1,issue_key_2,issue_key_3)",
),
},
)

Expand Down Expand Up @@ -1697,7 +1723,7 @@ def test_check_comments_for_duplicates(self, mock_client):
# Set up return values
mock_comment = MagicMock()
mock_comment.body = "Marking as duplicate of TEST-1234"
mock_comment.author.name = "mock_user"
mock_comment.author.displayName = "mock_user"
mock_client.comments.return_value = [mock_comment]
mock_client.issue.return_value = "Successful Call!"

Expand Down
Loading