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
dependencies: [] # Do not download artifacts from the previous stages
artifacts:
expire_in: 1 week
expire_in: 2 week
when: always
paths:
- 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
import os
@@ -34,6 +34,8 @@ TEST_RELATED_APPS_DOWNLOAD_URLS_FILENAME = 'test_related_apps_download_urls.yml'
REPORT_TEMPLATE_FILEPATH = os.path.join(
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
SIZE_DIFFERENCE_BYTES_THRESHOLD = 500
BINARY_SIZE_METRIC_NAME = 'binary_size'

View File

@@ -20,7 +20,10 @@ from idf_ci_local.uploader import AppUploader
from prettytable import PrettyTable
from .constants import BINARY_SIZE_METRIC_NAME
from .constants import CI_DASHBOARD_API
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 RETRY_JOB_PICTURE_LINK
from .constants import RETRY_JOB_PICTURE_PATH
@@ -56,6 +59,14 @@ class ReportGenerator:
self.output_filepath = self.title.lower().replace(' ', '_') + '.html'
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
def get_download_link_for_url(url: str) -> str:
if url:
@@ -83,14 +94,36 @@ class ReportGenerator:
report_url: str = get_artifacts_url(job_id, output_filepath)
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:
# we're using bootstrap table
table_str = table_str.replace(
'<table>',
'<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)
@@ -136,17 +169,23 @@ class ReportGenerator:
return report_sections
@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.
:param title: The title of the section.
: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 add_permalink: Whether to include a permalink in the report URL. Defaults to True.
:return: The formatted additional info section string.
"""
if count != 0 and report_url:
return f'- **{title}:** [{count}]({report_url}/#{format_permalink(title)})\n'
if add_permalink:
return f'- **{title}:** [{count}]({report_url}/#{format_permalink(title)})\n'
else:
return f'- **{title}:** [{count}]({report_url})\n'
else:
return f'- **{title}:** {count}\n'
@@ -673,7 +712,11 @@ class BuildReportGenerator(ReportGenerator):
return skipped_apps_table_section
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()
skipped_apps_report_parts = self.get_skipped_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._known_failure_cases_set = None
self.report_titles_map = {
'failed_yours': 'Failed Test Cases on Your branch (Excludes Known Failure Cases)',
'failed_others': 'Failed Test Cases on Other branches (Excludes Known Failure Cases)',
'failed_yours': 'Testcases failed ONLY on your branch (known failures are excluded)',
'failed_others': 'Testcases failed on your branch as well as on others (known failures are excluded)',
'failed_known': 'Known Failure Cases',
'skipped': 'Skipped Test Cases',
'succeeded': 'Succeeded Test Cases',
@@ -794,7 +837,7 @@ class TargetTestReportGenerator(ReportGenerator):
'Test Case',
'Test App Path',
'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',
'Create Known Failure Case Jira',
'Job URL',
@@ -803,9 +846,9 @@ class TargetTestReportGenerator(ReportGenerator):
row_attrs=['name', 'app_path', 'failure', 'dut_log_url', 'ci_job_url', 'ci_dashboard_url'],
value_functions=[
(
'Failures on your branch (40 latest testcases)',
lambda item: f'{getattr(item, "latest_failed_count", "")} '
f'/ {getattr(item, "latest_total_count", "")}',
'These test cases failed exclusively on your branch in the latest 40 runs',
lambda item: f'{getattr(item, "latest_failed_count", "")} / '
f'{getattr(item, "latest_total_count", "")}',
),
('Create Known Failure Case Jira', known_failure_issue_jira_fast_link),
],
@@ -901,7 +944,10 @@ class TargetTestReportGenerator(ReportGenerator):
self.succeeded_cases_report_file,
)
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'
return succeeded_cases_table_section
@@ -911,7 +957,7 @@ class TargetTestReportGenerator(ReportGenerator):
Generate a complete HTML report string by processing test cases.
: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()
skipped_cases_report_parts = self.get_skipped_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)
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.report_titles_map['succeeded'], len(succeeded_jobs)
)

View File

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

View File

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

View File

@@ -1,8 +1,15 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
@@ -19,114 +26,106 @@
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
rel="stylesheet"
/>
<!-- CSS content will be injected here during template rendering -->
<style>
.text-toggle,
.full-text {
cursor: pointer;
/* {{css_content}} */
/* Additional styles for disabled tabs */
.report-nav-tabs .nav-tab.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
th:nth-child(1),
td:nth-child(1) {
width: 5%;
/* Loading animation for tabs */
.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),
th:nth-child(3),
td:nth-child(3) {
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;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container-fluid">{{table}}</div>
<body data-pipeline-id="{{pipeline_id}}">
<!-- 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/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://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>
$(window).on("load", function () {
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>`
);
}
});
}
{{js_content}}
</script>
</body>
</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
- build_report.html
- test_related_apps_download_urls.yml
expire_in: 1 week
expire_in: 2 week
when: always
script:
- env
- python tools/ci/dynamic_pipelines/scripts/generate_report.py --report-type 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.
>>> 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.
: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(
f'{CI_DASHBOARD_API}/jobs/failure_ratio',
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:
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={
'source_commit_sha': source_commit_sha,
'target_commit_sha': target_commit_sha,
}
},
)
if response.status_code != 200:
print(f'Failed to fetch build info: {response.status_code} - {response.text}')
else:
response_data = response.json()
build_info_map = {
f"{info['app_path']}_{info['config_name']}_{info['target']}": info
for info in response_data.get('data', [])
f'{info["app_path"]}_{info["config_name"]}_{info["target"]}': info for info in response_data.get('data', [])
}
return build_info_map
@@ -253,7 +255,7 @@ def get_repository_file_url(file_path: str) -> str:
def known_failure_issue_jira_fast_link(_item: TestCase) -> str:
"""
Generate a JIRA fast link for known issues with relevant test case details.
Generate a JIRA fast link for known issues with relevant test case details.
"""
jira_url = os.getenv('JIRA_SERVER')
jira_pid = os.getenv('JIRA_KNOWN_FAILURE_PID', '10514')
@@ -269,14 +271,14 @@ def known_failure_issue_jira_fast_link(_item: TestCase) -> str:
'issuetype': jira_issuetype,
'summary': f'[Test Case]{_item.name}',
'description': (
f"job_url: {quote(_item.ci_job_url, safe=':/')}\n\n"
f"dut_log_url: {quote(_item.dut_log_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'ci_dashboard_url: {_item.ci_dashboard_url}\n\n'
),
'components': jira_component,
'priority': jira_priority,
'assignee': jira_assignee,
'versions': jira_affected_versions
'versions': jira_affected_versions,
}
query_string = urlencode(params)
return f'<a href="{base_url}{query_string}">Create</a>'