ci: improve the dynamic pipeline report

This commit is contained in:
Aleksei Apaseev
2025-04-16 17:01:58 +08:00
parent 574f037b1e
commit c25f87920a
13 changed files with 9311 additions and 376 deletions

View File

@@ -9,7 +9,7 @@ generate_failed_jobs_report:
when: always when: always
dependencies: [] # Do not download artifacts from the previous stages dependencies: [] # Do not download artifacts from the previous stages
artifacts: artifacts:
expire_in: 1 week expire_in: 2 week
when: always when: always
paths: paths:
- job_report.html - job_report.html

View File

@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2024 Espressif Systems (Shanghai) CO LTD # SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
import os import os
@@ -34,6 +34,8 @@ TEST_RELATED_APPS_DOWNLOAD_URLS_FILENAME = 'test_related_apps_download_urls.yml'
REPORT_TEMPLATE_FILEPATH = os.path.join( REPORT_TEMPLATE_FILEPATH = os.path.join(
IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'report.template.html' IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'report.template.html'
) )
CSS_STYLES_FILEPATH = os.path.join(IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'styles.css')
JS_SCRIPTS_FILEPATH = os.path.join(IDF_PATH, 'tools', 'ci', 'dynamic_pipelines', 'templates', 'scripts.js')
TOP_N_APPS_BY_SIZE_DIFF = 10 TOP_N_APPS_BY_SIZE_DIFF = 10
SIZE_DIFFERENCE_BYTES_THRESHOLD = 500 SIZE_DIFFERENCE_BYTES_THRESHOLD = 500
BINARY_SIZE_METRIC_NAME = 'binary_size' BINARY_SIZE_METRIC_NAME = 'binary_size'

View File

@@ -20,7 +20,10 @@ from idf_ci_local.uploader import AppUploader
from prettytable import PrettyTable from prettytable import PrettyTable
from .constants import BINARY_SIZE_METRIC_NAME from .constants import BINARY_SIZE_METRIC_NAME
from .constants import CI_DASHBOARD_API
from .constants import COMMENT_START_MARKER from .constants import COMMENT_START_MARKER
from .constants import CSS_STYLES_FILEPATH
from .constants import JS_SCRIPTS_FILEPATH
from .constants import REPORT_TEMPLATE_FILEPATH from .constants import REPORT_TEMPLATE_FILEPATH
from .constants import RETRY_JOB_PICTURE_LINK from .constants import RETRY_JOB_PICTURE_LINK
from .constants import RETRY_JOB_PICTURE_PATH from .constants import RETRY_JOB_PICTURE_PATH
@@ -56,6 +59,14 @@ class ReportGenerator:
self.output_filepath = self.title.lower().replace(' ', '_') + '.html' self.output_filepath = self.title.lower().replace(' ', '_') + '.html'
self.additional_info = '' self.additional_info = ''
@property
def get_commit_summary(self) -> str:
"""Return a formatted commit summary string."""
return (
f'with CI commit SHA: {self.commit_id[:8]}, '
f'local commit SHA: {os.getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "")[:8]}'
)
@staticmethod @staticmethod
def get_download_link_for_url(url: str) -> str: def get_download_link_for_url(url: str) -> str:
if url: if url:
@@ -83,14 +94,36 @@ class ReportGenerator:
report_url: str = get_artifacts_url(job_id, output_filepath) report_url: str = get_artifacts_url(job_id, output_filepath)
return report_url return report_url
@staticmethod
def _load_file_content(filepath: str) -> str:
"""
Load the content of a file as string
:param filepath: Path to the file to load
:return: Content of the file as string
"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
return f.read()
except (IOError, FileNotFoundError) as e:
print(f'Warning: Could not read file {filepath}: {e}')
return ''
def generate_html_report(self, table_str: str) -> str: def generate_html_report(self, table_str: str) -> str:
# we're using bootstrap table # we're using bootstrap table
table_str = table_str.replace( table_str = table_str.replace(
'<table>', '<table>',
'<table data-toggle="table" data-search-align="left" data-search="true" data-sticky-header="true">', '<table data-toggle="table" data-search-align="left" data-search="true" data-sticky-header="true">',
) )
with open(REPORT_TEMPLATE_FILEPATH) as fr:
template = fr.read() template = self._load_file_content(REPORT_TEMPLATE_FILEPATH)
css_content = self._load_file_content(CSS_STYLES_FILEPATH)
js_content = self._load_file_content(JS_SCRIPTS_FILEPATH)
template = template.replace('{{css_content}}', css_content)
template = template.replace('{{js_content}}', js_content)
template = template.replace('{{pipeline_id}}', str(self.pipeline_id))
template = template.replace('{{apiBaseUrl}}', CI_DASHBOARD_API)
return template.replace('{{title}}', self.title).replace('{{table}}', table_str) return template.replace('{{title}}', self.title).replace('{{table}}', table_str)
@@ -136,17 +169,23 @@ class ReportGenerator:
return report_sections return report_sections
@staticmethod @staticmethod
def generate_additional_info_section(title: str, count: int, report_url: t.Optional[str] = None) -> str: def generate_additional_info_section(
title: str, count: int, report_url: t.Optional[str] = None, add_permalink: bool = True
) -> str:
""" """
Generate a section for the additional info string. Generate a section for the additional info string.
:param title: The title of the section. :param title: The title of the section.
:param count: The count of test cases. :param count: The count of test cases.
:param report_url: The URL of the report. If count = 0, only the count will be included. :param report_url: The URL of the report. If count = 0, only the count will be included.
:param add_permalink: Whether to include a permalink in the report URL. Defaults to True.
:return: The formatted additional info section string. :return: The formatted additional info section string.
""" """
if count != 0 and report_url: if count != 0 and report_url:
if add_permalink:
return f'- **{title}:** [{count}]({report_url}/#{format_permalink(title)})\n' return f'- **{title}:** [{count}]({report_url}/#{format_permalink(title)})\n'
else:
return f'- **{title}:** [{count}]({report_url})\n'
else: else:
return f'- **{title}:** {count}\n' return f'- **{title}:** {count}\n'
@@ -673,7 +712,11 @@ class BuildReportGenerator(ReportGenerator):
return skipped_apps_table_section return skipped_apps_table_section
def _get_report_str(self) -> str: def _get_report_str(self) -> str:
self.additional_info = f'**Build Summary (with commit {self.commit_id[:8]}):**\n' self.additional_info = (
f'**Build Summary ({self.get_commit_summary}):**\n'
'\n'
'> Note: Binary artifacts stored in MinIO are retained for 4 DAYS from their build date\n'
)
failed_apps_report_parts = self.get_failed_apps_report_parts() failed_apps_report_parts = self.get_failed_apps_report_parts()
skipped_apps_report_parts = self.get_skipped_apps_report_parts() skipped_apps_report_parts = self.get_skipped_apps_report_parts()
built_apps_report_parts = self.get_built_apps_report_parts() built_apps_report_parts = self.get_built_apps_report_parts()
@@ -700,8 +743,8 @@ class TargetTestReportGenerator(ReportGenerator):
self.test_cases = test_cases self.test_cases = test_cases
self._known_failure_cases_set = None self._known_failure_cases_set = None
self.report_titles_map = { self.report_titles_map = {
'failed_yours': 'Failed Test Cases on Your branch (Excludes Known Failure Cases)', 'failed_yours': 'Testcases failed ONLY on your branch (known failures are excluded)',
'failed_others': 'Failed Test Cases on Other branches (Excludes Known Failure Cases)', 'failed_others': 'Testcases failed on your branch as well as on others (known failures are excluded)',
'failed_known': 'Known Failure Cases', 'failed_known': 'Known Failure Cases',
'skipped': 'Skipped Test Cases', 'skipped': 'Skipped Test Cases',
'succeeded': 'Succeeded Test Cases', 'succeeded': 'Succeeded Test Cases',
@@ -794,7 +837,7 @@ class TargetTestReportGenerator(ReportGenerator):
'Test Case', 'Test Case',
'Test App Path', 'Test App Path',
'Failure Reason', 'Failure Reason',
'Failures on your branch (40 latest testcases)', 'These test cases failed exclusively on your branch in the latest 40 runs',
'Dut Log URL', 'Dut Log URL',
'Create Known Failure Case Jira', 'Create Known Failure Case Jira',
'Job URL', 'Job URL',
@@ -803,9 +846,9 @@ class TargetTestReportGenerator(ReportGenerator):
row_attrs=['name', 'app_path', 'failure', 'dut_log_url', 'ci_job_url', 'ci_dashboard_url'], row_attrs=['name', 'app_path', 'failure', 'dut_log_url', 'ci_job_url', 'ci_dashboard_url'],
value_functions=[ value_functions=[
( (
'Failures on your branch (40 latest testcases)', 'These test cases failed exclusively on your branch in the latest 40 runs',
lambda item: f'{getattr(item, "latest_failed_count", "")} ' lambda item: f'{getattr(item, "latest_failed_count", "")} / '
f'/ {getattr(item, "latest_total_count", "")}', f'{getattr(item, "latest_total_count", "")}',
), ),
('Create Known Failure Case Jira', known_failure_issue_jira_fast_link), ('Create Known Failure Case Jira', known_failure_issue_jira_fast_link),
], ],
@@ -901,7 +944,10 @@ class TargetTestReportGenerator(ReportGenerator):
self.succeeded_cases_report_file, self.succeeded_cases_report_file,
) )
self.additional_info += self.generate_additional_info_section( self.additional_info += self.generate_additional_info_section(
self.report_titles_map['succeeded'], len(succeeded_test_cases), succeeded_cases_report_url self.report_titles_map['succeeded'],
len(succeeded_test_cases),
succeeded_cases_report_url,
add_permalink=False,
) )
self.additional_info += '\n' self.additional_info += '\n'
return succeeded_cases_table_section return succeeded_cases_table_section
@@ -911,7 +957,7 @@ class TargetTestReportGenerator(ReportGenerator):
Generate a complete HTML report string by processing test cases. Generate a complete HTML report string by processing test cases.
:return: Complete HTML report string. :return: Complete HTML report string.
""" """
self.additional_info = f'**Test Case Summary (with commit {self.commit_id[:8]}):**\n' self.additional_info = f'**Test Case Summary ({self.get_commit_summary}):**\n'
failed_cases_report_parts = self.get_failed_cases_report_parts() failed_cases_report_parts = self.get_failed_cases_report_parts()
skipped_cases_report_parts = self.get_skipped_cases_report_parts() skipped_cases_report_parts = self.get_skipped_cases_report_parts()
succeeded_cases_report_parts = self.get_succeeded_cases_report_parts() succeeded_cases_report_parts = self.get_succeeded_cases_report_parts()
@@ -960,7 +1006,7 @@ class JobReportGenerator(ReportGenerator):
) )
succeeded_jobs = self._filter_items(self.jobs, lambda job: job.is_success) succeeded_jobs = self._filter_items(self.jobs, lambda job: job.is_success)
self.additional_info = f'**Job Summary (with commit {self.commit_id[:8]}):**\n' self.additional_info = f'**Job Summary ({self.get_commit_summary}):**\n'
self.additional_info += self.generate_additional_info_section( self.additional_info += self.generate_additional_info_section(
self.report_titles_map['succeeded'], len(succeeded_jobs) self.report_titles_map['succeeded'], len(succeeded_jobs)
) )

View File

@@ -5,7 +5,7 @@
artifacts: artifacts:
paths: paths:
- target_test_report.html - target_test_report.html
expire_in: 1 week expire_in: 2 week
when: always when: always
fast_pipeline:pipeline_ended:always_failed: fast_pipeline:pipeline_ended:always_failed:

View File

@@ -9,7 +9,7 @@ generate_pytest_report:
- failed_cases.html - failed_cases.html
- skipped_cases.html - skipped_cases.html
- succeeded_cases.html - succeeded_cases.html
expire_in: 1 week expire_in: 2 week
when: always when: always
script: script:

View File

@@ -1,8 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{title}}</title> <title>{{title}}</title>
<link
rel="shortcut icon"
href="https://www.espressif.com/sites/all/themes/espressif/favicon.ico"
type="image/vnd.microsoft.icon"
/>
<!-- External CSS libraries -->
<link <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet" rel="stylesheet"
@@ -19,114 +26,106 @@
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
rel="stylesheet" rel="stylesheet"
/> />
<!-- CSS content will be injected here during template rendering -->
<style> <style>
.text-toggle, /* {{css_content}} */
.full-text { /* Additional styles for disabled tabs */
cursor: pointer; .report-nav-tabs .nav-tab.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
} }
th:nth-child(1),
td:nth-child(1) { /* Loading animation for tabs */
width: 5%; .report-nav-tabs .nav-tab.loading:after {
content: "";
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid rgba(0, 0, 0, 0.2);
border-left-color: #333;
border-radius: 50%;
margin-left: 8px;
animation: spin 1s linear infinite;
} }
th:nth-child(2),
td:nth-child(2), @keyframes spin {
th:nth-child(3), to {
td:nth-child(3) { transform: rotate(360deg);
width: 30%;
} }
th,
td {
overflow: hidden;
text-overflow: ellipsis;
}
h2 {
margin-top: 10px;
}
.copy-link-icon {
font-size: 20px;
margin-left: 10px;
color: #8f8f97;
cursor: pointer;
}
.copy-link-icon:hover {
color: #282b2c;
} }
</style> </style>
</head> </head>
<body> <body data-pipeline-id="{{pipeline_id}}">
<div class="container-fluid">{{table}}</div> <!-- Navigation progress bar -->
<div class="nav-progress-container">
<div class="nav-progress-bar" id="nav-progress-bar"></div>
</div>
<div class="container-fluid">
<!-- Report header section -->
<header class="report-header">
<div class="logo-container">
<img
src="https://www.espressif.com/sites/all/themes/espressif/logo-black.svg"
alt="Espressif Logo"
class="logo"
/>
</div>
<div class="title-container">
<h1>
<span style="color: #333">Dynamic Pipeline Report</span>
</h1>
</div>
<div class="spacer"></div>
</header>
<!-- Search and controls section -->
<div class="row mb-4">
<div class="col-md-8">
</div>
<div class="col-md-4 text-end">
<div class="action-buttons">
<button
class="btn btn-esp btn-sm"
id="expand-all-tables"
>
<i class="fas fa-table"></i> Expand All
</button>
<button
class="btn btn-outline-secondary btn-sm ms-2"
id="collapse-all-tables"
>
<i class="fas fa-minus"></i> Collapse All
</button>
</div>
</div>
</div>
<div class="table-responsive">
<div class="table-container">{{table}}</div>
</div>
</div>
<!-- Floating action buttons -->
<div class="floating-actions">
<div class="floating-action-btn back-to-top" id="back-to-top">
<i class="fas fa-arrow-up"></i>
</div>
</div>
<!-- JavaScript libraries -->
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.22.1/dist/bootstrap-table.min.js"></script> <script src="https://unpkg.com/bootstrap-table@1.22.1/dist/bootstrap-table.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.0/dist/extensions/sticky-header/bootstrap-table-sticky-header.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.0/dist/extensions/sticky-header/bootstrap-table-sticky-header.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.22.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.10.21/tableExport.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.22.1/dist/extensions/filter-control/bootstrap-table-filter-control.min.js"></script>
<!-- Custom scripts -->
<script> <script>
$(window).on("load", function () { {{js_content}}
var hash = window.location.hash;
if (hash) {
setTimeout(function () {
$("html, body").animate(
{ scrollTop: $(hash).offset().top },
100
);
}, 100);
}
});
</script>
<script>
$(document).ready(function () {
scrollToHashLocation();
setupTextToggles();
setupEventHandlers();
});
function setupEventHandlers() {
$(window).on("load", scrollToHashLocation);
$("body").on("click", ".toggle-link", toggleText);
}
function scrollToHashLocation() {
const hash = window.location.hash;
if (hash) {
setTimeout(() => {
$("html, body").animate(
{ scrollTop: $(hash).offset().top },
100
);
}, 100);
}
}
function copyPermalink(anchorId) {
const fullUrl = `${window.location.origin}${window.location.pathname}${anchorId}`;
history.pushState(null, null, anchorId);
navigator.clipboard.writeText(fullUrl);
scrollToHashLocation();
}
function toggleText(e) {
e.preventDefault();
const link = $(this),
textSpan = link.siblings(".full-text"),
toggleSpan = link.siblings(".text-toggle");
const visible = textSpan.is(":visible");
link.text(visible ? "Show More" : "Show Less");
textSpan.toggle();
toggleSpan.toggle();
}
function setupTextToggles() {
$("table.table td").each(function () {
var cell = $(this);
if (cell.text().length > 100) {
var originalText = cell.text();
var displayText =
originalText.substring(0, 100) + "...";
cell.html(
`<span class="text-toggle">${displayText}</span><span class="full-text" style="display: none;">${originalText}</span><a href="#" class="toggle-link">Show More</a>`
);
}
});
}
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,11 @@ generate_pytest_build_report:
- skipped_apps.html - skipped_apps.html
- build_report.html - build_report.html
- test_related_apps_download_urls.yml - test_related_apps_download_urls.yml
expire_in: 1 week expire_in: 2 week
when: always when: always
script: script:
- env
- python tools/ci/dynamic_pipelines/scripts/generate_report.py --report-type build - python tools/ci/dynamic_pipelines/scripts/generate_report.py --report-type build
- python tools/ci/previous_stage_job_status.py --stage build - python tools/ci/previous_stage_job_status.py --stage build

View File

@@ -58,7 +58,7 @@ def parse_testcases_from_filepattern(junit_report_filepattern: str) -> t.List[Te
""" """
Parses test cases from XML files matching the provided file pattern. Parses test cases from XML files matching the provided file pattern.
>>> test_cases = parse_testcases_from_filepattern("path/to/your/junit/reports/*.xml") >>> test_cases = parse_testcases_from_filepattern('path/to/your/junit/reports/*.xml')
:param junit_report_filepattern: The file pattern to match XML files containing JUnit test reports. :param junit_report_filepattern: The file pattern to match XML files containing JUnit test reports.
:return: List[TestCase]: A list of TestCase objects parsed from the XML files. :return: List[TestCase]: A list of TestCase objects parsed from the XML files.
@@ -124,7 +124,10 @@ def fetch_failed_jobs(commit_id: str) -> t.List[GitlabJob]:
response = requests.post( response = requests.post(
f'{CI_DASHBOARD_API}/jobs/failure_ratio', f'{CI_DASHBOARD_API}/jobs/failure_ratio',
headers={'CI-Job-Token': CI_JOB_TOKEN}, headers={'CI-Job-Token': CI_JOB_TOKEN},
json={'job_names': failed_job_names, 'exclude_branches': [os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', '')]}, json={
'job_names': failed_job_names,
'exclude_branches': [os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', '')],
},
) )
if response.status_code != 200: if response.status_code != 200:
print(f'Failed to fetch jobs failure rate data: {response.status_code} with error: {response.text}') print(f'Failed to fetch jobs failure rate data: {response.status_code} with error: {response.text}')
@@ -185,15 +188,14 @@ def fetch_app_metrics(
json={ json={
'source_commit_sha': source_commit_sha, 'source_commit_sha': source_commit_sha,
'target_commit_sha': target_commit_sha, 'target_commit_sha': target_commit_sha,
} },
) )
if response.status_code != 200: if response.status_code != 200:
print(f'Failed to fetch build info: {response.status_code} - {response.text}') print(f'Failed to fetch build info: {response.status_code} - {response.text}')
else: else:
response_data = response.json() response_data = response.json()
build_info_map = { build_info_map = {
f"{info['app_path']}_{info['config_name']}_{info['target']}": info f'{info["app_path"]}_{info["config_name"]}_{info["target"]}': info for info in response_data.get('data', [])
for info in response_data.get('data', [])
} }
return build_info_map return build_info_map
@@ -269,14 +271,14 @@ def known_failure_issue_jira_fast_link(_item: TestCase) -> str:
'issuetype': jira_issuetype, 'issuetype': jira_issuetype,
'summary': f'[Test Case]{_item.name}', 'summary': f'[Test Case]{_item.name}',
'description': ( 'description': (
f"job_url: {quote(_item.ci_job_url, safe=':/')}\n\n" f'job_url: {quote(_item.ci_job_url, safe=":/")}\n\n'
f"dut_log_url: {quote(_item.dut_log_url, safe=':/')}\n\n" f'dut_log_url: {quote(_item.dut_log_url, safe=":/")}\n\n'
f'ci_dashboard_url: {_item.ci_dashboard_url}\n\n' f'ci_dashboard_url: {_item.ci_dashboard_url}\n\n'
), ),
'components': jira_component, 'components': jira_component,
'priority': jira_priority, 'priority': jira_priority,
'assignee': jira_assignee, 'assignee': jira_assignee,
'versions': jira_affected_versions 'versions': jira_affected_versions,
} }
query_string = urlencode(params) query_string = urlencode(params)
return f'<a href="{base_url}{query_string}">Create</a>' return f'<a href="{base_url}{query_string}">Create</a>'