Skip to content

Commit a9f4915

Browse files
authored
Add default_with_assignee_and_status action (#75)
1 parent f80de31 commit a9f4915

File tree

8 files changed

+625
-1
lines changed

8 files changed

+625
-1
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The system reads the action configuration from a YAML file, one per environment.
1818
Below is a full example of an action configuration:
1919
```yaml
2020
action: src.jbi.whiteboard_actions.default
21+
allow_private: false
2122
contact: [[email protected]]
2223
description: example configuration
2324
enabled: true
@@ -31,6 +32,12 @@ A bit more about the different fields...
3132
- string
3233
- default: [src.jbi.whiteboard_actions.default](src/jbi/whiteboard_actions/default.py)
3334
- The specified Python module must be available in the `PYTHONPATH`
35+
- `allow_private` (optional)
36+
- bool [true, false]
37+
- default: false
38+
- If false bugs that are not public will not be synchronized. Note that in order to synchronize
39+
private bugs the bugzilla user that JBI runs as must be in the security groups that are making
40+
the bug private.
3441
- `contact`
3542
- list of strings
3643
- If an issue arises with the workflow, communication will be established with these contacts
@@ -54,6 +61,37 @@ A bit more about the different fields...
5461
[View 'prod' configurations here.](config/config.prod.yaml)
5562

5663

64+
## Default with assignee and status action
65+
The `src.jbi.whiteboard_actions.default_with_assignee_and_status` action adds some additional
66+
features on top of the default.
67+
68+
It will attempt to assign the Jira issue the same person as the bug is assigned to. This relies on
69+
the user using the same email address in both Bugzilla and Jira. If the user does not exist in Jira
70+
then the assignee is cleared from the Jira issue.
71+
72+
The action supports setting the Jira issues's status when the Bugzilla status and resolution change.
73+
This is defined using a mapping on a per-project basis configured in the `status_map` field of the
74+
`parameters` field.
75+
76+
An example configuration:
77+
```yaml
78+
action: src.jbi.whiteboard_actions.default_with_assignee_and_status
79+
contact: [[email protected]]
80+
description: example configuration
81+
enabled: true
82+
parameters:
83+
jira_project_key: EXMPL
84+
whiteboard_tag: example
85+
status_map:
86+
NEW: "In Progress"
87+
FIXED: "Closed"
88+
```
89+
90+
In this case if the bug changes to the NEW status the action will attempt to set the linked Jira
91+
issue status to "In Progress". If the bug changes to RESOLVED FIXED it will attempt to set the
92+
linked Jira issue status to "Closed". If the bug changes to a status not listed in `status_map` then
93+
no change will be made to the Jira issue.
94+
5795
### Custom Actions
5896
If you're looking for a unique capability for your team's data flow, you can add your own python methods and functionality[...read more here.](src/jbi/whiteboard_actions/README.md)
5997

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ testpaths = [
5656
ignore='third_party'
5757
ignore-patterns = "tests/*"
5858
extension-pkg-whitelist = "pydantic"
59+
[tool.pylint.SIMILARITIES]
60+
ignore-signatures = "yes"
5961

6062
[tool.isort]
6163
profile = "black"

src/jbi/bugzilla.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ class BugzillaWebhookEvent(BaseModel):
4242
target: Optional[str]
4343
routing_key: Optional[str]
4444

45+
def changed_fields(self) -> Optional[List[str]]:
46+
"""Returns the names of changed fields in a bug"""
47+
if self.changes:
48+
return [c.field for c in self.changes]
49+
50+
# Private bugs don't include the changes field in the event, but the
51+
# field names are in the routing key.
52+
if self.routing_key is not None and self.routing_key[0:11] == "bug.modify:":
53+
return self.routing_key[11:].split(",")
54+
55+
return None
56+
4557

4658
class BugzillaWebhookAttachment(BaseModel):
4759
"""Bugzilla Attachment Object"""

src/jbi/services.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def get_jira():
1616
url=settings.jira_base_url,
1717
username=settings.jira_username,
1818
password=settings.jira_api_key, # package calls this param 'password' but actually expects an api key
19+
cloud=True, # we run against an instance of Jira cloud
1920
)
2021

2122

src/jbi/whiteboard_actions/default.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ def comment_create_or_noop(
8383
)
8484
return {"status": "comment", "jira_response": jira_response}
8585

86+
def jira_comments_for_update( # pylint: disable=no-self-use
87+
self,
88+
payload: BugzillaWebhookRequest,
89+
):
90+
"""Returns the comments to post to Jira for a changed bug"""
91+
return payload.map_as_comments()
92+
93+
def update_issue(
94+
self,
95+
payload: BugzillaWebhookRequest,
96+
bug_obj: BugzillaBug,
97+
linked_issue_key: str,
98+
is_new: bool,
99+
):
100+
"""Allows sub-classes to modify the Jira issue in response to a bug event"""
101+
86102
def bug_create_or_update(
87103
self, payload: BugzillaWebhookRequest, bug_obj: BugzillaBug
88104
): # pylint: disable=too-many-locals
@@ -105,7 +121,7 @@ def bug_create_or_update(
105121
key=linked_issue_key, fields=bug_obj.map_as_jira_issue()
106122
)
107123

108-
comments = payload.map_as_comments()
124+
comments = self.jira_comments_for_update(payload)
109125
jira_response_comments = []
110126
for i, comment in enumerate(comments):
111127
logger.debug(
@@ -119,6 +135,9 @@ def bug_create_or_update(
119135
issue_key=linked_issue_key, comment=comment
120136
)
121137
)
138+
139+
self.update_issue(payload, bug_obj, linked_issue_key, is_new=False)
140+
122141
return {
123142
"status": "update",
124143
"jira_responses": [jira_response_update, jira_response_comments],
@@ -201,6 +220,9 @@ def create_and_link_issue(
201220
link_url=bugzilla_url,
202221
title="Bugzilla Ticket",
203222
)
223+
224+
self.update_issue(payload, bug_obj, jira_key_in_response, is_new=True)
225+
204226
return {
205227
"status": "create",
206228
"bugzilla_response": bugzilla_response,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
Extended action that provides some additional features over the default:
3+
* Updates the Jira assignee when the bug's assignee changes.
4+
* Optionally updates the Jira status when the bug's resolution or status changes.
5+
6+
`init` is required; and requires at minimum the
7+
`whiteboard_tag` and `jira_project_key`. `status_map` is optional.
8+
9+
`init` should return a __call__able
10+
"""
11+
import logging
12+
13+
from src.jbi.bugzilla import BugzillaBug, BugzillaWebhookRequest
14+
from src.jbi.whiteboard_actions.default import DefaultExecutor
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def init(status_map=None, **kwargs):
20+
"""Function that takes required and optional params and returns a callable object"""
21+
return AssigneeAndStatusExecutor(status_map=status_map or {}, **kwargs)
22+
23+
24+
class AssigneeAndStatusExecutor(DefaultExecutor):
25+
"""Callable class that encapsulates the default_with_assignee_and_status action."""
26+
27+
def __init__(self, status_map, **kwargs):
28+
"""Initialize AssigneeAndStatusExecutor Object"""
29+
super().__init__(**kwargs)
30+
self.status_map = status_map
31+
32+
def jira_comments_for_update(
33+
self,
34+
payload: BugzillaWebhookRequest,
35+
):
36+
"""Returns the comments to post to Jira for a changed bug"""
37+
return payload.map_as_comments(
38+
status_log_enabled=False, assignee_log_enabled=False
39+
)
40+
41+
def update_issue(
42+
self,
43+
payload: BugzillaWebhookRequest,
44+
bug_obj: BugzillaBug,
45+
linked_issue_key: str,
46+
is_new: bool,
47+
):
48+
changed_fields = payload.event.changed_fields() or []
49+
50+
log_context = {
51+
"bug": {
52+
"id": bug_obj.id,
53+
"status": bug_obj.status,
54+
"resolution": bug_obj.resolution,
55+
"assigned_to": bug_obj.assigned_to,
56+
},
57+
"jira": linked_issue_key,
58+
"changed_fields": changed_fields,
59+
}
60+
61+
def clear_assignee():
62+
# New tickets already have no assignee.
63+
if not is_new:
64+
logger.debug("Clearing assignee", extra=log_context)
65+
self.jira_client.update_issue_field(
66+
key=linked_issue_key, fields={"assignee": None}
67+
)
68+
69+
# If this is a new issue or if the bug's assignee has changed then
70+
# update the assignee.
71+
if is_new or "assigned_to" in changed_fields:
72+
if bug_obj.assigned_to == "[email protected]":
73+
clear_assignee()
74+
else:
75+
logger.debug(
76+
"Attempting to update assignee",
77+
extra=log_context,
78+
)
79+
# Look up this user in Jira
80+
users = self.jira_client.user_find_by_user_string(
81+
query=bug_obj.assigned_to
82+
)
83+
if len(users) == 1:
84+
try:
85+
# There doesn't appear to be an easy way to verify that
86+
# this user can be assigned to this issue, so just try
87+
# and do it.
88+
self.jira_client.update_issue_field(
89+
key=linked_issue_key,
90+
fields={"assignee": {"accountId": users[0]["accountId"]}},
91+
)
92+
except IOError as exception:
93+
logger.debug(
94+
"Setting assignee failed: %s", exception, extra=log_context
95+
)
96+
# If that failed then just fall back to clearing the
97+
# assignee.
98+
clear_assignee()
99+
else:
100+
logger.debug("No assignee found", extra=log_context)
101+
clear_assignee()
102+
103+
# If this is a new issue or if the bug's status or resolution has
104+
# changed then update the issue status.
105+
if is_new or "status" in changed_fields or "resolution" in changed_fields:
106+
# We use resolution if one exists or status otherwise.
107+
status = bug_obj.resolution or bug_obj.status
108+
109+
if status in self.status_map:
110+
logger.debug(
111+
"Updating Jira status to %s",
112+
self.status_map[status],
113+
extra=log_context,
114+
)
115+
self.jira_client.set_issue_status(
116+
linked_issue_key, self.status_map[status]
117+
)
118+
else:
119+
logger.debug(
120+
"Bug status was not in the status map.",
121+
extra={**log_context, "status_map": self.status_map},
122+
)

tests/unit/jbi/test_bugzilla.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,41 @@ def test_get_jira_labels_multiple():
6464
def test_extract_see_also(see_also, expected):
6565
test_bug = bugzilla.BugzillaBug(id=0, see_also=see_also)
6666
assert test_bug.extract_from_see_also() == expected
67+
68+
69+
def test_payload_changes(webhook_request_example):
70+
assert webhook_request_example.event.changed_fields() is None
71+
72+
webhook_request_example.event = bugzilla.BugzillaWebhookEvent.parse_obj(
73+
{
74+
"action": "modify",
75+
"routing_key": "bug.modify",
76+
"target": "bug",
77+
"changes": [
78+
{"field": "assigned_to", "removed": "", "added": "dtownsend"},
79+
{"field": "status", "removed": "UNCONFIRMED", "added": "NEW"},
80+
],
81+
"time": "2022-03-23T20:10:17.495000+00:00",
82+
"user": {
83+
"id": 123456,
84+
"login": "[email protected]",
85+
"real_name": "Nobody [ :nobody ]",
86+
},
87+
}
88+
)
89+
assert webhook_request_example.event.changed_fields() == ["assigned_to", "status"]
90+
91+
webhook_request_example.event = bugzilla.BugzillaWebhookEvent.parse_obj(
92+
{
93+
"action": "modify",
94+
"routing_key": "bug.modify:assigned_to,status",
95+
"target": "bug",
96+
"time": "2022-03-23T20:10:17.495000+00:00",
97+
"user": {
98+
"id": 123456,
99+
"login": "[email protected]",
100+
"real_name": "Nobody [ :nobody ]",
101+
},
102+
}
103+
)
104+
assert webhook_request_example.event.changed_fields() == ["assigned_to", "status"]

0 commit comments

Comments
 (0)