Skip to content
24 changes: 16 additions & 8 deletions docs/source/config-file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,10 @@ The config file is made up of multiple parts
* Sync description
* :code:`'title'`
* Sync title
* :code:`{'transition': True/'CUSTOM_TRANSITION'}`
* Sync status (open/closed), Sync only status/Attempt to transition JIRA ticket to CUSTOM_TRANSITION on upstream closure
* :code:`{'transition': 'CUSTOM_TRANSITION', 'issue_types': ['Bug', 'Story']}`
* Sync status (open/closed). Attempt to transition JIRA ticket to CUSTOM_TRANSITION on upstream closure.
* ``issue_types`` is optional and may be omitted. When present, the transition only fires if the
downstream JIRA issue type is in the list.
* :code:`{'on_close': {'apply_labels': ['label', ...]}}`
* When the upstream issue is closed, apply additional labels on the corresponding Jira ticket.
* :code:`github_markdown`
Expand All @@ -192,17 +194,23 @@ The config file is made up of multiple parts
* You can add your projects here. The 'project' field is associated with downstream JIRA projects, and 'component' with
downstream components. You can add the following to the :code:`pr_updates` array:

* :code:`{'merge_transition': 'CUSTOM_TRANSITION'}`
* Sync when upstream PR gets merged. Attempts to transition JIRA ticket to CUSTOM_TRANSITION on upstream merge
* :code:`{'merge_transition': 'CUSTOM_TRANSITION', 'branches': ['release-*', 'main'], 'issue_types': ['Bug', 'Story']}`
* Sync when upstream PR gets merged. Attempts to transition JIRA ticket to CUSTOM_TRANSITION on upstream merge.
* ``branches`` and ``issue_types`` are optional and either may be omitted. ``branches`` accepts glob patterns
and restricts the transition to PRs whose target branch matches. ``issue_types`` restricts it to matching
downstream JIRA issue types.
* :code:`{'link_transition': 'CUSTOM_TRANSITION'}`
* Sync when upstream PR gets linked. Attempts to transition JIRA ticket to CUSTOM_TRANSITION on upstream link
* Sync when upstream PR gets linked. Attempts to transition JIRA ticket to CUSTOM_TRANSITION on upstream link.

* You can add the following to the mapping array. This array will map an upstream field to the downstream counterpart
with XXX replaced.
using either a template or a mapping table.

* :code:`{'fixVersion': 'Test XXX'}`
* Maps upstream milestone (suppose it's called 'milestone') to downstream fixVersion with a mapping (for our
example it would be 'Test milestone')
* String template format. Maps upstream milestone (suppose it's called 'milestone') to downstream fixVersion
with a mapping (for our example it would be 'Test milestone').
* :code:`{'fixVersion': {'0.9.0': 'Product 8.1', '1.0.0': 'Product 9.0'}}`
* Dict lookup format. Maps specific upstream milestones to specific downstream fixVersions.
Milestones not present in the dict are left unchanged.
Comment thread
webbnh marked this conversation as resolved.

* It is strongly encouraged for teams to use the :code:`owner` field. If configured, owners will be alerted if Sync2Jira
finds duplicate downstream issues. Further the owner will be used as a default in case the program is unable to find a
Expand Down
62 changes: 44 additions & 18 deletions sync2jira/downstream_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -1070,30 +1070,56 @@ def _update_transition(client, existing, issue):
"""
Helper function to update the transition of a downstream JIRA issue.

Supports an optional ``issue_types`` filter on transition entries in
``issue_updates``. When present, the transition only fires if the
downstream JIRA issue's type is in the list.

:param jira.client.JIRA client: JIRA client
:param jira.resource.Issue existing: Existing JIRA issue
:param sync2jira.intermediary.Issue issue: Upstream issue
:returns: Nothing
"""
# If the user added a custom closed status, attempt to close the
# downstream JIRA ticket
for entry in issue.downstream.get("issue_updates", []):
if not isinstance(entry, dict) or "transition" not in entry:
continue

# First get the closed status from the config file
t = filter(lambda d: "transition" in d, issue.downstream.get("issue_updates", []))
closed_status = next(t)["transition"]
if (
closed_status is not True
and issue.status == "Closed"
and existing.fields.status.name.upper() != closed_status.upper()
):
# Now we need to update the status of the JIRA issue
# First add a comment indicating the change (in case it doesn't go through)
hyperlink = f"[Upstream issue|{issue.url}]"
comment_body = f"{hyperlink} closed. Attempting transition to {closed_status}."
client.add_comment(existing, comment_body)
# Ensure that closed_status is a valid choice
# Find all possible transactions (i.e., change states) we could do
change_status(client, existing, closed_status, issue)
closed_status = entry["transition"]

# Normalize legacy True value to "Closed"
if closed_status is True:
closed_status = "Closed"
if not isinstance(closed_status, str):
log.warning(
"Ignoring malformed transition value %r (expected a string) in "
"issue_updates config for %s",
closed_status,
existing.key,
)
continue
Comment thread
webbnh marked this conversation as resolved.

type_filters = entry.get("issue_types")
if type_filters is not None:
jira_type = existing.fields.issuetype.name
if jira_type not in type_filters:
log.info(
"Skipping issue transition '%s': issue type '%s' not in %s",
closed_status,
jira_type,
type_filters,
)
continue

if (
issue.status == "Closed"
and existing.fields.status.name.upper() != closed_status.upper()
):
Comment thread
webbnh marked this conversation as resolved.
hyperlink = f"[Upstream issue|{issue.url}]"
comment_body = (
f"{hyperlink} closed. Attempting transition to {closed_status}."
)
client.add_comment(existing, comment_body)
change_status(client, existing, closed_status, issue)
return


def _update_title(issue, existing):
Expand Down
74 changes: 65 additions & 9 deletions sync2jira/downstream_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#
# Authors: Ralph Bean <rbean@redhat.com>
# Built-In Modules
import fnmatch
import logging

# 3rd Party Modules
Expand Down Expand Up @@ -124,12 +125,12 @@ def update_jira_issue(existing, pr, client):
remote_link = dict(url=pr.url, title=f"[PR] {pr.title}")
d_issue.attach_link(client, existing, remote_link)

# Only synchronize link_transition for listings that op-in
# Only synchronize merge_transition for listings that opt-in
if any("merge_transition" in item for item in updates) and "merged" in pr.suffix:
log.info("Looking for new merged_transition")
update_transition(client, existing, pr, "merge_transition")

# Only synchronize merge_transition for listings that op-in
# Only synchronize link_transition for listings that opt-in
# and a link comment has been created
if (
any("link_transition" in item for item in updates)
Expand All @@ -140,25 +141,80 @@ def update_jira_issue(existing, pr, client):
update_transition(client, existing, pr, "link_transition")


def _matches_transition_filters(transition_config, pr, existing):
"""
Check whether a transition config entry's optional filters match the
current PR and downstream JIRA issue.

Supported filters:
- ``branches``: list of glob patterns matched against ``pr.base_branch``
- ``issue_types``: list of JIRA issue type names matched against the
existing downstream issue's type

:param dict transition_config: Single pr_updates entry
:param sync2jira.intermediary.PR pr: Upstream PR
:param jira.resources.Issue existing: Existing downstream JIRA issue
:returns: True if all filters pass (or no filters are specified)
:rtype: bool
"""
branch_filters = transition_config.get("branches")
if branch_filters is not None:
if not pr.base_branch or not any(
fnmatch.fnmatch(pr.base_branch, pattern) for pattern in branch_filters
):
log.info(
"Skipping transition: branch '%s' does not match %s",
pr.base_branch,
branch_filters,
)
return False

type_filters = transition_config.get("issue_types")
if type_filters is not None:
jira_type = existing.fields.issuetype.name
if jira_type not in type_filters:
log.info(
"Skipping transition: issue type '%s' does not match %s",
jira_type,
type_filters,
)
return False

return True


def update_transition(client, existing, pr, transition_type):
"""
Helper function to update the transition of a downstream JIRA issue.

Applies optional ``branches`` and ``issue_types`` filters from the
pr_updates config entry before executing the transition.

:param jira.client.JIRA client: JIRA client
:param jira.resource.Issue existing: Existing JIRA issue
:param sync2jira.intermediary.PR pr: Upstream issue
:param string transition_type: Transition type (link vs merged)
:returns: Nothing
"""
# Get our closed status
closed_status = next(
filter(lambda d: transition_type in d, pr.downstream.get("pr_updates", {}))
)[transition_type]
pr_updates = pr.downstream.get("pr_updates")
if not pr_updates:
return

# Update the state
d_issue.change_status(client, existing, closed_status, pr)
for entry in pr_updates:
if transition_type not in entry:
continue
if not _matches_transition_filters(entry, pr, existing):
continue
d_issue.change_status(client, existing, entry[transition_type], pr)
log.info(f"Updated {transition_type} for issue {pr.title}")
return

log.info(f"Updated {transition_type} for issue {pr.title}")
log.info(
"No matching %s entry for PR %s (branch=%s)",
transition_type,
pr.title,
pr.base_branch,
)


def sync_with_jira(pr, config):
Expand Down
21 changes: 18 additions & 3 deletions sync2jira/intermediary.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def __init__(
id_,
suffix,
match,
base_branch=None,
downstream=None,
):
self.source = source
Expand All @@ -166,6 +167,7 @@ def __init__(
# self.tags = tags
# self.fixVersion = fixVersion
self.priority = priority
self.base_branch = base_branch

# JIRA treats utf-8 characters in ways we don't totally understand, so scrub content down to
# simple ascii characters right from the start.
Expand Down Expand Up @@ -226,6 +228,10 @@ def from_github(cls, upstream, pr, suffix, config, action=None):
elif suffix not in lifecycle:
suffix = "open"

base_branch = (
pr["base"].get("ref") if isinstance(pr.get("base"), dict) else None
)
Comment thread
webbnh marked this conversation as resolved.

# Return our PR object
return cls(
source=upstream_source,
Expand All @@ -247,6 +253,7 @@ def from_github(cls, upstream, pr, suffix, config, action=None):
# upstream_id=issue['number'],
suffix=suffix,
match=match,
base_branch=base_branch,
)


Expand All @@ -268,15 +275,23 @@ def map_fixVersion(mapping, issue):
"""
Helper function to perform any fixVersion mapping.

Supports two formats:
- String template: ``"Product XXX"`` — replaces ``XXX`` with the milestone value
- Dict lookup: ``{"0.9.0": "Product 8.1", ...}`` — maps milestone to a
specific fixVersion; unmapped milestones are left unchanged

:param Dict mapping: Mapping dict we are given
:param Dict issue: Upstream issue object
"""
# Get our fixVersion mapping
fixVersion_map = next(filter(lambda d: "fixVersion" in d, mapping))["fixVersion"]

# Now update the fixVersion
if issue["milestone"]:
issue["milestone"] = fixVersion_map.replace("XXX", issue["milestone"])
if isinstance(fixVersion_map, dict):
issue["milestone"] = fixVersion_map.get(
issue["milestone"], issue["milestone"]
)
else:
issue["milestone"] = fixVersion_map.replace("XXX", issue["milestone"])


JIRA_REFERENCE = re.compile(r"\bJIRA:\s*([A-Z][A-Z0-9]*-\d+)\b")
Expand Down
Loading
Loading