mirror of
https://github.com/espressif/esp-idf.git
synced 2025-08-01 03:34:32 +02:00
ci: improve the dynamic pipeline report
This commit is contained in:
@@ -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
|
||||
|
@@ -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'
|
||||
|
@@ -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)
|
||||
)
|
||||
|
@@ -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:
|
||||
|
@@ -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:
|
||||
|
@@ -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>
|
||||
|
1127
tools/ci/dynamic_pipelines/templates/scripts.js
Normal file
1127
tools/ci/dynamic_pipelines/templates/scripts.js
Normal file
File diff suppressed because it is too large
Load Diff
1095
tools/ci/dynamic_pipelines/templates/styles.css
Normal file
1095
tools/ci/dynamic_pipelines/templates/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>'
|
||||
|
Reference in New Issue
Block a user