Skip to content

Commit 289893a

Browse files
Merge pull request #31 from mozilla/def-action
Adding Default Action
2 parents 1900395 + 3bd07dc commit 289893a

File tree

17 files changed

+607
-65
lines changed

17 files changed

+607
-65
lines changed

config/config.dev.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ actions:
55
devtest:
66
contact: tbd
77
description: DevTest whiteboard tag
8+
enabled: true
89
parameters:
9-
jira_project_key: OSS
10+
jira_project_key: JST
1011
whiteboard_tag: devtest

config/config.prod.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,11 @@ actions:
9090
parameters:
9191
jira_project_key: RELOPS
9292
whiteboard_tag: relops
93+
snt:
94+
action: src.jbi.whiteboard_actions.default
95+
contact: tbd
96+
description: Search/NewTab Team Tag
97+
enabled: false
98+
parameters:
99+
jira_project_key: SNT
100+
whiteboard_tag: snt

infra/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ CMD ./infra/detect_secrets_helper.sh
9393

9494

9595
# 'test' stage runs our unit tests with pytest and
96-
# coverage. Build will fail if test coverage is under 80%
96+
# coverage.
9797
FROM development AS test
9898
CMD ./infra/test.sh
9999

infra/test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ CURRENT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
66
BASE_DIR="$(dirname "$CURRENT_DIR")"
77

88
coverage run --rcfile "${BASE_DIR}/pyproject.toml" -m pytest
9-
coverage report --rcfile "${BASE_DIR}/pyproject.toml" -m --fail-under 80
9+
coverage report --rcfile "${BASE_DIR}/pyproject.toml" -m --fail-under 75
1010
coverage html --rcfile "${BASE_DIR}/pyproject.toml"

src/app/environment.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ class Settings(BaseSettings):
1717
env: str = "dev"
1818

1919
# Jira
20-
jira_base_url: str = "https://jira.allizom.org/"
20+
jira_base_url: str = "https://mozit-test.atlassian.net/"
21+
jira_issue_url: str = f"{jira_base_url}/browse/%s"
2122
jira_username: str
2223
jira_password: str
2324

2425
# Bugzilla
25-
bugzilla_base_url: str = "https://bugzilla-dev.allizom.org/"
26+
bugzilla_base_url: str = "https://bugzilla-dev.allizom.org"
27+
bugzilla_bug_url: str = f"{bugzilla_base_url}/show_bug.cgi?id=%s"
2628
bugzilla_api_key: str
2729

2830
# Logging

src/app/monitor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi.responses import JSONResponse
88

99
from src.app import environment
10-
from src.jbi.service import jbi_service_health_map
10+
from src.jbi.services import jbi_service_health_map
1111

1212
api_router = APIRouter(tags=["Monitor"])
1313

src/jbi/bugzilla.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""
2+
Bugzilla Typed Objects for ease of use throughout JBI
3+
View additional bugzilla webhook documentation here: https://bugzilla.mozilla.org/page.cgi?id=webhooks.html
4+
5+
"""
6+
import datetime
7+
import logging
8+
import traceback
9+
from typing import Dict, List, Optional, Tuple
10+
from urllib.parse import ParseResult, urlparse
11+
12+
from pydantic import BaseModel # pylint: disable=no-name-in-module
13+
14+
bugzilla_logger = logging.getLogger("src.jbi.bugzilla")
15+
16+
17+
class BugzillaWebhookUser(BaseModel):
18+
"""Bugzilla User Object"""
19+
20+
id: int
21+
login: str
22+
real_name: str
23+
24+
25+
class BugzillaWebhookEventChange(BaseModel):
26+
"""Bugzilla Change Object"""
27+
28+
field: str
29+
removed: str
30+
added: str
31+
32+
33+
class BugzillaWebhookEvent(BaseModel):
34+
"""Bugzilla Event Object"""
35+
36+
action: str
37+
time: Optional[datetime.datetime]
38+
user: Optional[BugzillaWebhookUser]
39+
changes: Optional[List[BugzillaWebhookEventChange]]
40+
target: Optional[str]
41+
routing_key: Optional[str]
42+
43+
44+
class BugzillaWebhookAttachment(BaseModel):
45+
"""Bugzilla Attachment Object"""
46+
47+
content_type: Optional[str]
48+
creation_time: Optional[datetime.datetime]
49+
description: Optional[str]
50+
file_name: Optional[str]
51+
flags: Optional[List]
52+
id: int
53+
is_obsolete: Optional[bool]
54+
is_patch: Optional[bool]
55+
is_private: Optional[bool]
56+
last_change_time: Optional[datetime.datetime]
57+
58+
59+
class BugzillaWebhookComment(BaseModel):
60+
"""Bugzilla Comment Object"""
61+
62+
body: Optional[str]
63+
id: Optional[int]
64+
number: Optional[int]
65+
is_private: Optional[bool]
66+
creation_time: Optional[datetime.datetime]
67+
68+
def is_comment_description(self) -> bool:
69+
"""Used to determine if `self` is a description or comment."""
70+
return self.number == 0
71+
72+
def is_comment_generic(self) -> bool:
73+
"""All comments after comment-0 are generic"""
74+
is_description = self.is_comment_description()
75+
return not is_description
76+
77+
def is_private_comment(self) -> bool:
78+
"""Helper function to determine if this comment private--not accessible or open"""
79+
return bool(self.is_private)
80+
81+
82+
class BugzillaBug(BaseModel):
83+
"""Bugzilla Bug Object"""
84+
85+
id: int
86+
is_private: Optional[bool]
87+
type: Optional[str]
88+
product: Optional[str]
89+
component: Optional[str]
90+
whiteboard: Optional[str]
91+
keywords: Optional[List]
92+
flags: Optional[List]
93+
status: Optional[str]
94+
resolution: Optional[str]
95+
see_also: Optional[List]
96+
summary: Optional[str]
97+
severity: Optional[str]
98+
priority: Optional[str]
99+
creator: Optional[str]
100+
assigned_to: Optional[str]
101+
comment: Optional[BugzillaWebhookComment]
102+
103+
def get_whiteboard_as_list(self):
104+
"""Convert string whiteboard into list, splitting on ']' and removing '['."""
105+
if self.whiteboard is not None:
106+
split_list = self.whiteboard.replace("[", "").split("]")
107+
return [x.strip() for x in split_list if x not in ["", " "]]
108+
return []
109+
110+
def get_whiteboard_with_brackets_as_list(self):
111+
"""Convert string whiteboard into list, splitting on ']' and removing '['; then re-adding."""
112+
wb_list = self.get_whiteboard_as_list()
113+
if wb_list is not None and len(wb_list) > 0:
114+
return [f"[{element}]" for element in wb_list]
115+
return []
116+
117+
def get_jira_labels(self):
118+
"""
119+
whiteboard labels are added as a convenience for users to search in jira;
120+
bugzilla is an expected label in Jira
121+
"""
122+
return (
123+
["bugzilla"]
124+
+ self.get_whiteboard_as_list()
125+
+ self.get_whiteboard_with_brackets_as_list()
126+
)
127+
128+
def get_potential_whiteboard_config_list(self):
129+
"""Get all possible whiteboard_tag configuration values"""
130+
converted_list: List = []
131+
for whiteboard in self.get_whiteboard_as_list():
132+
converted_tag = self.convert_whiteboard_to_tag(whiteboard=whiteboard)
133+
if converted_tag not in [None, "", " "]:
134+
converted_list.append(converted_tag)
135+
136+
return converted_list
137+
138+
def convert_whiteboard_to_tag(self, whiteboard): # pylint: disable=no-self-use
139+
"""Extract tag from whiteboard label"""
140+
_exists = whiteboard not in (" ", "")
141+
if not _exists:
142+
return ""
143+
return whiteboard.split(sep="-", maxsplit=1)[0].lower()
144+
145+
def map_as_jira_issue(self):
146+
"""Extract bug info as jira issue dictionary"""
147+
type_map: dict = {"enhancement": "Task", "task": "Task", "defect": "Bug"}
148+
return {
149+
"summary": self.summary,
150+
"labels": self.get_jira_labels(),
151+
"issuetype": {"name": type_map.get(self.type, "Task")},
152+
}
153+
154+
def extract_from_see_also(self):
155+
"""Extract Jira Issue Key from see_also if jira url present"""
156+
if not self.see_also and len(self.see_also) > 0:
157+
return None
158+
159+
for url in self.see_also: # pylint: disable=not-an-iterable
160+
try:
161+
parsed_url: ParseResult = urlparse(url=url)
162+
expected_hosts = ["jira", "atlassian"]
163+
164+
if any( # pylint: disable=use-a-generator
165+
[part in expected_hosts for part in parsed_url.hostname.split(".")]
166+
):
167+
parsed_jira_key = parsed_url.path.split("/")[-1]
168+
return parsed_jira_key
169+
except Exception: # pylint: disable=broad-except
170+
# Try parsing all see_also fields; log errors.
171+
bugzilla_logger.debug(traceback.format_exc())
172+
return None
173+
174+
175+
class BugzillaWebhookRequest(BaseModel):
176+
"""Bugzilla Webhook Request Object"""
177+
178+
webhook_id: int
179+
webhook_name: str
180+
event: BugzillaWebhookEvent
181+
bug: Optional[BugzillaBug]
182+
183+
def map_as_jira_comment(self):
184+
"""Extract comment from Webhook Event"""
185+
comment: BugzillaWebhookComment = self.bug.comment
186+
commenter: BugzillaWebhookUser = self.event.user
187+
comment_body: str = comment.body
188+
body = f"*({commenter.login})* commented: \n{{quote}}{comment_body}{{quote}}"
189+
return body
190+
191+
def map_as_jira_description(self):
192+
"""Extract description as comment from Webhook Event"""
193+
comment: BugzillaWebhookComment = self.bug.comment
194+
comment_body: str = comment.body
195+
body = f"*(description)*: \n{{quote}}{comment_body}{{quote}}"
196+
return body
197+
198+
def map_as_tuple_of_field_dict_and_comments(
199+
self,
200+
status_log_enabled: bool = True,
201+
assignee_log_enabled: bool = True,
202+
) -> Tuple[Dict, List]:
203+
"""Extract update dict and comment list from Webhook Event"""
204+
205+
comments: List = []
206+
bug: BugzillaBug = self.bug # type: ignore
207+
208+
update_fields: dict = {
209+
"summary": bug.summary,
210+
"labels": bug.get_jira_labels(),
211+
}
212+
if self.event.changes:
213+
user = self.event.user.login if self.event.user else "unknown"
214+
for change in self.event.changes:
215+
216+
if status_log_enabled and change.field in ["status", "resolution"]:
217+
comments.append(
218+
{
219+
"modified by": user,
220+
"resolution": bug.resolution,
221+
"status": bug.status,
222+
}
223+
)
224+
225+
if assignee_log_enabled and change.field in ["assigned_to", "assignee"]:
226+
comments.append({"assignee": bug.assigned_to})
227+
228+
if change.field == "reporter":
229+
update_fields[change.field] = change.added
230+
231+
return update_fields, comments
232+
233+
234+
class BugzillaApiResponse(BaseModel):
235+
"""Bugzilla Response Object"""
236+
237+
faults: Optional[List]
238+
bugs: Optional[List[BugzillaBug]]

src/jbi/configuration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic import ValidationError
88

99
from src.app import environment
10-
from src.jbi.model import Actions
10+
from src.jbi.models import Actions
1111

1212
settings = environment.get_settings()
1313
jbi_logger = logging.getLogger("src.jbi")

src/jbi/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Custom exceptions for JBI"""
2+
3+
4+
class IgnoreInvalidRequestError(Exception):
5+
"""Error thrown when requests are invalid and ignored"""
6+
7+
8+
class ActionError(Exception):
9+
"""Error occurred during Action handling"""
File renamed without changes.

0 commit comments

Comments
 (0)