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
20 changes: 20 additions & 0 deletions src/objects/jira_adf.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ def inline_text(
return node


def rule() -> dict[str, Any]:
return {"type": "rule"}


def table(*rows: dict[str, Any]) -> dict[str, Any]:
return {"type": "table", "content": list(rows)}


def table_row(*cells: dict[str, Any]) -> dict[str, Any]:
return {"type": "tableRow", "content": list(cells)}


def table_header_cell(*blocks: dict[str, Any]) -> dict[str, Any]:
return {"type": "tableHeader", "content": list(blocks)}


def table_cell(*blocks: dict[str, Any]) -> dict[str, Any]:
return {"type": "tableCell", "content": list(blocks)}


def adf_mention(account_id: str, display_stub: str) -> dict[str, Any]:
return {
"type": "mention",
Expand Down
9 changes: 7 additions & 2 deletions src/objects/jira_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def create_issue(
self,
project: str,
summary: str,
description: str,
description: str | dict[str, Any],
issue_type: str,
component: Optional[list[str]] = None,
epic: Optional[str] = None,
Expand Down Expand Up @@ -91,10 +91,15 @@ def create_issue(
Returns:
Issue: A Jira Issue object.
"""
adf_description = (
sanitize_jira_adf_doc(description)
if isinstance(description, dict)
else sanitize_jira_adf_doc(plain_text_to_adf_doc(description))
)
issue_dict = {
"project": {"key": project},
"summary": summary,
"description": sanitize_jira_adf_doc(plain_text_to_adf_doc(description)),
"description": adf_description,
"issuetype": {"name": issue_type},
}

Expand Down
163 changes: 95 additions & 68 deletions src/report/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
from src.objects.jira_adf import heading
from src.objects.jira_adf import inline_text
from src.objects.jira_adf import paragraph
from src.objects.jira_adf import rule
from src.objects.jira_adf import table
from src.objects.jira_adf import table_cell
from src.objects.jira_adf import table_header_cell
from src.objects.jira_adf import table_row
from src.objects.jira_base import Jira
from src.objects.job import Job
from src.objects.rule import Rule
Expand Down Expand Up @@ -144,8 +149,8 @@ def file_jira_issues(
rules=firewatch_config.failure_rules, # type: ignore
default_jira_project=firewatch_config.default_jira_project,
)
for rule in rule_matches:
rule_failure_pairs.append({"rule": rule, "failure": failure})
for failure_rule in rule_matches:
rule_failure_pairs.append({"rule": failure_rule, "failure": failure})

rule_failure_pairs = self.filter_priority_rule_failure_pairs(
rule_failure_pairs=rule_failure_pairs,
Expand Down Expand Up @@ -276,20 +281,20 @@ def report_success(self, job: Job, firewatch_config: Configuration) -> None:
"""
self.logger.info(f"Reporting job {job.name} success.")
date = datetime.now()
for rule in firewatch_config.success_rules if firewatch_config.success_rules else []:
for failure_rule in firewatch_config.success_rules if firewatch_config.success_rules else []:
labels = [
label
for label in self._get_issue_labels(
job_name=job.name,
type="success",
jira_additional_labels=rule.jira_additional_labels, # type: ignore
jira_additional_labels=failure_rule.jira_additional_labels, # type: ignore
jira_additional_labels_filepath=firewatch_config.additional_labels_file,
slack_channel=rule.slack_channel, # type: ignore
slack_user=rule.slack_user, # type: ignore
slack_channel=failure_rule.slack_channel, # type: ignore
slack_user=failure_rule.slack_user, # type: ignore
)
if label
]
self._safe_create_success_issue(firewatch_config, job, rule, date, labels)
self._safe_create_success_issue(firewatch_config, job, failure_rule, date, labels)

def _create_success_issue(
self,
Expand Down Expand Up @@ -398,12 +403,12 @@ def failure_matches_rule(
}
default_rule = FailureRule(default_rule_dict)

for rule in rules:
if rule.matches_failure(failure):
if rule.ignore:
ignored_rules.append(rule)
for failure_rule in rules:
if failure_rule.matches_failure(failure):
if failure_rule.ignore:
ignored_rules.append(failure_rule)
else:
matching_rules.append(rule)
matching_rules.append(failure_rule)

if (len(matching_rules) < 1) and (len(ignored_rules) < 1):
if default_rule not in matching_rules:
Expand Down Expand Up @@ -736,55 +741,83 @@ def _get_issue_description(
failed_test_name: Optional[str] = None,
success_issue: Optional[bool] = False,
jira: Optional[Jira] = None,
) -> str:
"""
Used to generate the description of a bug to be filed in Jira.

Args:
job_name (str): Name of job that failed.
build_id (str): Build ID of failure.
step_name (Optional[str]): Name of the step that failed.
classification (Optional[str]): Classification of the failure.
failure_type (Optional[str]): Failure type.
failed_test_name (Optional[str]): Name of failed test, else None
success_issue (Optional [bool]): Description for success issue if True else for failure

Returns:
str: String object representing the description.
"""
link_line_base_url = (
) -> dict[str, Any]:
prow_base_url = (
"https://qe-private-deck-ci.apps.ci.l2s4.p1.openshiftapps.com/view/gs/qe-private-deck/logs/"
if job.is_private_deck
else "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/"
)
link_line = f"*Prow Job Link:* [{job.name} #{job.build_id}|{link_line_base_url}{job.name}/{job.build_id}]"
build_id_line = f"*Build ID:* {job.build_id}"
job_history_link_line = f"*Job History:* [{job.name}|https://prow.ci.openshift.org/job-history/gs/test-platform-results/logs/{job.name}]"
firewatch_link_line = f"This {'issue' if success_issue else 'bug'} was filed using [firewatch in OpenShift CI|https://github.com/CSPI-QE/firewatch]"
prow_url = f"{prow_base_url}{job.name}/{job.build_id}"
job_history_url = f"https://prow.ci.openshift.org/job-history/gs/test-platform-results/logs/{job.name}"
fw_url = "https://github.com/CSPI-QE/firewatch"

blocks: list[dict[str, Any]] = [
paragraph(
inline_text("Prow Job Link: ", bold=True),
inline_text(f"{job.name} #{job.build_id}", url=prow_url),
),
paragraph(
inline_text("Build ID: ", bold=True),
inline_text(job.build_id or ""),
),
]

# If the issue is being created for a failure
if not success_issue:
classification_line = f"*Classification:* {classification}"
failed_step_line = f"*Failed Step:* {step_name}"
failed_test_line = f"*Failed Test:* {failed_test_name}" if failed_test_name else ""
blocks.append(
paragraph(
inline_text("Classification: ", bold=True),
inline_text(classification or ""),
)
)
blocks.append(
paragraph(
inline_text("Failed Step: ", bold=True),
inline_text(step_name or ""),
)
)
if failed_test_name:
blocks.append(
paragraph(
inline_text("Failed Test: ", bold=True),
inline_text(failed_test_name or ""),
)
)
blocks.append(
paragraph(
inline_text("Job History: ", bold=True),
inline_text(job.name or "", url=job_history_url),
)
)

past_bugs = self._get_past_bugs(
failed_step=step_name, # type: ignore
failure_type=failure_type, # type: ignore
failed_test_name=failed_test_name, # type: ignore
jira=jira, # type: ignore
)
description = f"{link_line}\n{build_id_line}\n{classification_line}\n{failed_step_line}\n{failed_test_line}\n{job_history_link_line}\n"
if past_bugs:
failed_test_portion = f" and failed test *{failed_test_name}*" if failed_test_name else ""
description += f"\n----\nHere are up to 10 related bugs produced by the step *{step_name}* and failed with failure type *{failure_type}*{failed_test_portion}:\n{self._get_past_bugs_table(issues=past_bugs, jira=jira)}\n" # type: ignore

# If the issue is being created for a success
else:
description = f"{link_line}\n{build_id_line}"
failed_test_portion = f" and failed test {failed_test_name}" if failed_test_name else ""
blocks.append(rule())
blocks.append(
paragraph(
inline_text(
f"Here are up to 10 related bugs produced by the step {step_name} "
f"and failed with failure type {failure_type}{failed_test_portion}:",
)
)
)
blocks.append(self._get_past_bugs_table(issues=past_bugs, jira=jira)) # type: ignore

description += f"\n{firewatch_link_line}"
issue_kind = "issue" if success_issue else "bug"
blocks.append(
paragraph(
inline_text(f"This {issue_kind} was filed using "),
inline_text("firewatch in OpenShift CI", url=fw_url),
inline_text("."),
)
)

return description
return adf_doc(*blocks)

def _get_issue_labels(
self,
Expand Down Expand Up @@ -938,27 +971,21 @@ def _get_past_bugs(
# Reduce to 10 most recent issues
return list_of_issues[:10]

def _get_past_bugs_table(self, issues: list[jira.Issue], jira: Jira) -> str:
"""
Used to build the table of bugs related to a specific step/failure type that will be put in issue descriptions

Args:
issues (list[jira.Issue]): A list of jira issues.
jira (Jira): Jia object.

Returns:
str: A string object representing the table of related Jira issues to be put in a bug description.
"""
table = "||Bug||Date Created||Assignee||"
issue_rows = []

def _get_past_bugs_table(self, issues: list[jira.Issue], jira: Jira) -> dict[str, Any]:
header = table_row(
table_header_cell(paragraph(inline_text("Bug", bold=True))),
table_header_cell(paragraph(inline_text("Date Created", bold=True))),
table_header_cell(paragraph(inline_text("Assignee", bold=True))),
)
rows = [header]
for issue in issues:
date_created = issue.get_field("created").split("T")[0]
assignee = issue.get_field("assignee")
issue_row = f"\n|[{issue.key}|{jira.url}/browse/{issue.key}]|{date_created}|{assignee}|"
issue_rows.append(issue_row)

for row in issue_rows:
table += row

return table
assignee = str(issue.get_field("assignee") or "Unassigned")
rows.append(
table_row(
table_cell(paragraph(inline_text(issue.key, url=f"{jira.url}/browse/{issue.key}"))),
table_cell(paragraph(inline_text(date_created))),
table_cell(paragraph(inline_text(assignee))),
)
)
return table(*rows)
21 changes: 21 additions & 0 deletions tests/unittests/objects/jira/test_jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,27 @@ def test_create_issue_sanitizes_empty_description_text_node(self, mock_jira):
fields = call_kwargs["json"]["fields"]
assert fields["description"]["content"][0]["content"][0]["text"] == " "

def test_create_issue_accepts_adf_dict_directly(self, mock_jira):
from src.objects.jira_adf import adf_doc, paragraph, inline_text

adf = adf_doc(
paragraph(inline_text("hello", bold=True)),
)
mock_jira.create_issue(
project="TEST",
summary="Summary",
description=adf,
issue_type="Bug",
)
call_kwargs = mock_jira.connection._session.post.call_args.kwargs
fields = call_kwargs["json"]["fields"]
desc = fields["description"]
assert desc["type"] == "doc"
assert desc["version"] == 1
assert desc["content"][0]["type"] == "paragraph"
assert desc["content"][0]["content"][0]["text"] == "hello"
assert {"type": "strong"} in desc["content"][0]["content"][0]["marks"]


class TestCreateIssueEpicSearch:
def test_epic_lookup_uses_unquoted_issue_key_for_cloud_compatibility(self, mock_jira):
Expand Down
37 changes: 37 additions & 0 deletions tests/unittests/objects/jira/test_jira_adf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
inline_text,
paragraph,
plain_text_to_adf_doc,
rule,
sanitize_jira_adf_doc,
table,
table_cell,
table_header_cell,
table_row,
)


Expand Down Expand Up @@ -208,6 +213,38 @@ def test_non_dict_non_str_returns_empty(self):
assert description_to_plain_text_for_search(0) == "" # type: ignore[arg-type]


class TestRule:
def test_rule_produces_horizontal_divider(self):
assert rule() == {"type": "rule"}


class TestTable:
def test_table_wraps_rows(self):
t = table(
table_row(table_header_cell(paragraph(inline_text("Col1")))),
table_row(table_cell(paragraph(inline_text("Val1")))),
)
assert t["type"] == "table"
assert len(t["content"]) == 2
assert t["content"][0]["type"] == "tableRow"
assert t["content"][0]["content"][0]["type"] == "tableHeader"
assert t["content"][1]["content"][0]["type"] == "tableCell"

def test_table_cell_contains_block_content(self):
cell = table_cell(paragraph(inline_text("data")))
assert cell == {
"type": "tableCell",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "data"}]},
],
}

def test_table_header_cell_type(self):
cell = table_header_cell(paragraph(inline_text("Header")))
assert cell["type"] == "tableHeader"
assert cell["content"][0]["content"][0]["text"] == "Header"


class TestClosedByFirewatchAdf:
def test_returns_doc_with_link_to_repo(self):
doc = closed_by_firewatch_adf()
Expand Down
Loading