diff --git a/src/objects/jira_adf.py b/src/objects/jira_adf.py index afc3c95..e66d734 100644 --- a/src/objects/jira_adf.py +++ b/src/objects/jira_adf.py @@ -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", diff --git a/src/objects/jira_base.py b/src/objects/jira_base.py index 8b8a2d3..4c7c8e1 100644 --- a/src/objects/jira_base.py +++ b/src/objects/jira_base.py @@ -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, @@ -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}, } diff --git a/src/report/report.py b/src/report/report.py index e046a2e..abb71ac 100644 --- a/src/report/report.py +++ b/src/report/report.py @@ -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 @@ -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, @@ -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, @@ -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: @@ -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, @@ -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) diff --git a/tests/unittests/objects/jira/test_jira.py b/tests/unittests/objects/jira/test_jira.py index b6fa9d4..c354dcf 100644 --- a/tests/unittests/objects/jira/test_jira.py +++ b/tests/unittests/objects/jira/test_jira.py @@ -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): diff --git a/tests/unittests/objects/jira/test_jira_adf.py b/tests/unittests/objects/jira/test_jira_adf.py index db993ce..aee9123 100644 --- a/tests/unittests/objects/jira/test_jira_adf.py +++ b/tests/unittests/objects/jira/test_jira_adf.py @@ -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, ) @@ -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()