Skip to content

Commit 8ca9df6

Browse files
istein1Ilanit Steinclaude
authored
Add branch/type filters to transitions and dict-based fixVersion mapping (#460)
* Add branches and issue_types filters to transitions, dict-based fixVersion mapping, and tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix lint and formatting: remove unused fnmatch import, apply Black Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR #460 review feedback - Guard noisy log in update_transition when pr_updates is absent - Simplify base_branch extraction with dict reference instead of .get() - Split PR filter tests into separate units for _matches_transition_filters and update_transition - Add missing issue test scenarios: absent/empty issue_updates, none-match multiple entries, already-matching status, transition=True - Fix misleading "cumulative" comment in issue test docstring - Document branches, issue_types filters and dict-based fixVersion mapping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Harden transition guard and clarify pr_updates docs - Replace `closed_status is not True` with `isinstance(closed_status, str)` to safely skip any non-string value (True, False, None, etc.) - Show branches/issue_types as keys in the same dict in pr_updates docs - Add test scenario for transition: False Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR #460 second review: consolidate docs, normalize transition True - Consolidate merge_transition and issue_updates transition doc entries into single entries showing optional filters, per reviewer suggestion - Fix mapping array description wording - Normalize legacy transition: True to "Closed" (confirmed by Ralph) - Update test expectation for True normalization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address PR #460 third review: fix doc example and add malformed config warning Use CUSTOM_TRANSITION placeholder in pr_updates doc example for consistency, and log a warning when a non-string transition value is encountered in issue_updates config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Ilanit Stein <istein@istein-thinkpadt14gen5.raanaii.csb> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d6a41c commit 8ca9df6

7 files changed

Lines changed: 554 additions & 39 deletions

File tree

docs/source/config-file.rst

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,10 @@ The config file is made up of multiple parts
167167
* Sync description
168168
* :code:`'title'`
169169
* Sync title
170-
* :code:`{'transition': True/'CUSTOM_TRANSITION'}`
171-
* Sync status (open/closed), Sync only status/Attempt to transition JIRA ticket to CUSTOM_TRANSITION on upstream closure
170+
* :code:`{'transition': 'CUSTOM_TRANSITION', 'issue_types': ['Bug', 'Story']}`
171+
* Sync status (open/closed). Attempt to transition JIRA ticket to CUSTOM_TRANSITION on upstream closure.
172+
* ``issue_types`` is optional and may be omitted. When present, the transition only fires if the
173+
downstream JIRA issue type is in the list.
172174
* :code:`{'on_close': {'apply_labels': ['label', ...]}}`
173175
* When the upstream issue is closed, apply additional labels on the corresponding Jira ticket.
174176
* :code:`github_markdown`
@@ -192,17 +194,23 @@ The config file is made up of multiple parts
192194
* You can add your projects here. The 'project' field is associated with downstream JIRA projects, and 'component' with
193195
downstream components. You can add the following to the :code:`pr_updates` array:
194196

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

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

203208
* :code:`{'fixVersion': 'Test XXX'}`
204-
* Maps upstream milestone (suppose it's called 'milestone') to downstream fixVersion with a mapping (for our
205-
example it would be 'Test milestone')
209+
* String template format. Maps upstream milestone (suppose it's called 'milestone') to downstream fixVersion
210+
with a mapping (for our example it would be 'Test milestone').
211+
* :code:`{'fixVersion': {'0.9.0': 'Product 8.1', '1.0.0': 'Product 9.0'}}`
212+
* Dict lookup format. Maps specific upstream milestones to specific downstream fixVersions.
213+
Milestones not present in the dict are left unchanged.
206214

207215
* It is strongly encouraged for teams to use the :code:`owner` field. If configured, owners will be alerted if Sync2Jira
208216
finds duplicate downstream issues. Further the owner will be used as a default in case the program is unable to find a

sync2jira/downstream_issue.py

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,30 +1070,56 @@ def _update_transition(client, existing, issue):
10701070
"""
10711071
Helper function to update the transition of a downstream JIRA issue.
10721072
1073+
Supports an optional ``issue_types`` filter on transition entries in
1074+
``issue_updates``. When present, the transition only fires if the
1075+
downstream JIRA issue's type is in the list.
1076+
10731077
:param jira.client.JIRA client: JIRA client
10741078
:param jira.resource.Issue existing: Existing JIRA issue
10751079
:param sync2jira.intermediary.Issue issue: Upstream issue
10761080
:returns: Nothing
10771081
"""
1078-
# If the user added a custom closed status, attempt to close the
1079-
# downstream JIRA ticket
1082+
for entry in issue.downstream.get("issue_updates", []):
1083+
if not isinstance(entry, dict) or "transition" not in entry:
1084+
continue
10801085

1081-
# First get the closed status from the config file
1082-
t = filter(lambda d: "transition" in d, issue.downstream.get("issue_updates", []))
1083-
closed_status = next(t)["transition"]
1084-
if (
1085-
closed_status is not True
1086-
and issue.status == "Closed"
1087-
and existing.fields.status.name.upper() != closed_status.upper()
1088-
):
1089-
# Now we need to update the status of the JIRA issue
1090-
# First add a comment indicating the change (in case it doesn't go through)
1091-
hyperlink = f"[Upstream issue|{issue.url}]"
1092-
comment_body = f"{hyperlink} closed. Attempting transition to {closed_status}."
1093-
client.add_comment(existing, comment_body)
1094-
# Ensure that closed_status is a valid choice
1095-
# Find all possible transactions (i.e., change states) we could do
1096-
change_status(client, existing, closed_status, issue)
1086+
closed_status = entry["transition"]
1087+
1088+
# Normalize legacy True value to "Closed"
1089+
if closed_status is True:
1090+
closed_status = "Closed"
1091+
if not isinstance(closed_status, str):
1092+
log.warning(
1093+
"Ignoring malformed transition value %r (expected a string) in "
1094+
"issue_updates config for %s",
1095+
closed_status,
1096+
existing.key,
1097+
)
1098+
continue
1099+
1100+
type_filters = entry.get("issue_types")
1101+
if type_filters is not None:
1102+
jira_type = existing.fields.issuetype.name
1103+
if jira_type not in type_filters:
1104+
log.info(
1105+
"Skipping issue transition '%s': issue type '%s' not in %s",
1106+
closed_status,
1107+
jira_type,
1108+
type_filters,
1109+
)
1110+
continue
1111+
1112+
if (
1113+
issue.status == "Closed"
1114+
and existing.fields.status.name.upper() != closed_status.upper()
1115+
):
1116+
hyperlink = f"[Upstream issue|{issue.url}]"
1117+
comment_body = (
1118+
f"{hyperlink} closed. Attempting transition to {closed_status}."
1119+
)
1120+
client.add_comment(existing, comment_body)
1121+
change_status(client, existing, closed_status, issue)
1122+
return
10971123

10981124

10991125
def _update_title(issue, existing):

sync2jira/downstream_pr.py

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#
1818
# Authors: Ralph Bean <rbean@redhat.com>
1919
# Built-In Modules
20+
import fnmatch
2021
import logging
2122

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

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

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

142143

144+
def _matches_transition_filters(transition_config, pr, existing):
145+
"""
146+
Check whether a transition config entry's optional filters match the
147+
current PR and downstream JIRA issue.
148+
149+
Supported filters:
150+
- ``branches``: list of glob patterns matched against ``pr.base_branch``
151+
- ``issue_types``: list of JIRA issue type names matched against the
152+
existing downstream issue's type
153+
154+
:param dict transition_config: Single pr_updates entry
155+
:param sync2jira.intermediary.PR pr: Upstream PR
156+
:param jira.resources.Issue existing: Existing downstream JIRA issue
157+
:returns: True if all filters pass (or no filters are specified)
158+
:rtype: bool
159+
"""
160+
branch_filters = transition_config.get("branches")
161+
if branch_filters is not None:
162+
if not pr.base_branch or not any(
163+
fnmatch.fnmatch(pr.base_branch, pattern) for pattern in branch_filters
164+
):
165+
log.info(
166+
"Skipping transition: branch '%s' does not match %s",
167+
pr.base_branch,
168+
branch_filters,
169+
)
170+
return False
171+
172+
type_filters = transition_config.get("issue_types")
173+
if type_filters is not None:
174+
jira_type = existing.fields.issuetype.name
175+
if jira_type not in type_filters:
176+
log.info(
177+
"Skipping transition: issue type '%s' does not match %s",
178+
jira_type,
179+
type_filters,
180+
)
181+
return False
182+
183+
return True
184+
185+
143186
def update_transition(client, existing, pr, transition_type):
144187
"""
145188
Helper function to update the transition of a downstream JIRA issue.
146189
190+
Applies optional ``branches`` and ``issue_types`` filters from the
191+
pr_updates config entry before executing the transition.
192+
147193
:param jira.client.JIRA client: JIRA client
148194
:param jira.resource.Issue existing: Existing JIRA issue
149195
:param sync2jira.intermediary.PR pr: Upstream issue
150196
:param string transition_type: Transition type (link vs merged)
151197
:returns: Nothing
152198
"""
153-
# Get our closed status
154-
closed_status = next(
155-
filter(lambda d: transition_type in d, pr.downstream.get("pr_updates", {}))
156-
)[transition_type]
199+
pr_updates = pr.downstream.get("pr_updates")
200+
if not pr_updates:
201+
return
157202

158-
# Update the state
159-
d_issue.change_status(client, existing, closed_status, pr)
203+
for entry in pr_updates:
204+
if transition_type not in entry:
205+
continue
206+
if not _matches_transition_filters(entry, pr, existing):
207+
continue
208+
d_issue.change_status(client, existing, entry[transition_type], pr)
209+
log.info(f"Updated {transition_type} for issue {pr.title}")
210+
return
160211

161-
log.info(f"Updated {transition_type} for issue {pr.title}")
212+
log.info(
213+
"No matching %s entry for PR %s (branch=%s)",
214+
transition_type,
215+
pr.title,
216+
pr.base_branch,
217+
)
162218

163219

164220
def sync_with_jira(pr, config):

sync2jira/intermediary.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def __init__(
155155
id_,
156156
suffix,
157157
match,
158+
base_branch=None,
158159
downstream=None,
159160
):
160161
self.source = source
@@ -166,6 +167,7 @@ def __init__(
166167
# self.tags = tags
167168
# self.fixVersion = fixVersion
168169
self.priority = priority
170+
self.base_branch = base_branch
169171

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

231+
base_branch = (
232+
pr["base"].get("ref") if isinstance(pr.get("base"), dict) else None
233+
)
234+
229235
# Return our PR object
230236
return cls(
231237
source=upstream_source,
@@ -247,6 +253,7 @@ def from_github(cls, upstream, pr, suffix, config, action=None):
247253
# upstream_id=issue['number'],
248254
suffix=suffix,
249255
match=match,
256+
base_branch=base_branch,
250257
)
251258

252259

@@ -268,15 +275,23 @@ def map_fixVersion(mapping, issue):
268275
"""
269276
Helper function to perform any fixVersion mapping.
270277
278+
Supports two formats:
279+
- String template: ``"Product XXX"`` — replaces ``XXX`` with the milestone value
280+
- Dict lookup: ``{"0.9.0": "Product 8.1", ...}`` — maps milestone to a
281+
specific fixVersion; unmapped milestones are left unchanged
282+
271283
:param Dict mapping: Mapping dict we are given
272284
:param Dict issue: Upstream issue object
273285
"""
274-
# Get our fixVersion mapping
275286
fixVersion_map = next(filter(lambda d: "fixVersion" in d, mapping))["fixVersion"]
276287

277-
# Now update the fixVersion
278288
if issue["milestone"]:
279-
issue["milestone"] = fixVersion_map.replace("XXX", issue["milestone"])
289+
if isinstance(fixVersion_map, dict):
290+
issue["milestone"] = fixVersion_map.get(
291+
issue["milestone"], issue["milestone"]
292+
)
293+
else:
294+
issue["milestone"] = fixVersion_map.replace("XXX", issue["milestone"])
280295

281296

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

0 commit comments

Comments
 (0)