mirror of
https://github.com/espressif/esp-idf.git
synced 2025-10-02 10:00:57 +02:00
ci: generate report for disabled apps
This commit is contained in:
@@ -313,3 +313,22 @@ build_child_pipeline:
|
||||
- artifact: build_child_pipeline.yml
|
||||
job: generate_build_child_pipeline
|
||||
strategy: depend
|
||||
|
||||
generate_disabled_apps_report:
|
||||
extends:
|
||||
- .build_template
|
||||
tags: [fast_run, shiny]
|
||||
dependencies: # set dependencies to null to avoid missing artifacts issue
|
||||
needs:
|
||||
- pipeline_variables
|
||||
- job: baseline_manifest_sha
|
||||
optional: true
|
||||
artifacts:
|
||||
paths:
|
||||
- disabled_report.html
|
||||
expire_in: 1 week
|
||||
when: always
|
||||
script:
|
||||
- pip install dominate idf-build-apps>=2.12.0
|
||||
- run_cmd python tools/ci/gen_disabled_report.py --output disabled_report.html --verbose --enable-preview-targets
|
||||
- echo "Report generated at https://${CI_PAGES_HOSTNAME}:${CI_SERVER_PORT}/-/esp-idf/-/jobs/${CI_JOB_ID}/artifacts/disabled_report.html"
|
||||
|
@@ -18,6 +18,7 @@ tools/ci/dynamic_pipelines/**/*
|
||||
tools/ci/envsubst.py
|
||||
tools/ci/executable-list.txt
|
||||
tools/ci/fix_empty_prototypes.sh
|
||||
tools/ci/gen_disabled_report.py
|
||||
tools/ci/generate_rules.py
|
||||
tools/ci/get-full-sources.sh
|
||||
tools/ci/get_all_test_results.py
|
||||
|
980
tools/ci/gen_disabled_report.py
Normal file
980
tools/ci/gen_disabled_report.py
Normal file
@@ -0,0 +1,980 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
|
||||
from dominate import document
|
||||
from dominate.tags import a
|
||||
from dominate.tags import button
|
||||
from dominate.tags import div
|
||||
from dominate.tags import h1
|
||||
from dominate.tags import h3
|
||||
from dominate.tags import input_
|
||||
from dominate.tags import label
|
||||
from dominate.tags import li
|
||||
from dominate.tags import script
|
||||
from dominate.tags import span
|
||||
from dominate.tags import strong
|
||||
from dominate.tags import style
|
||||
from dominate.tags import table
|
||||
from dominate.tags import tbody
|
||||
from dominate.tags import td
|
||||
from dominate.tags import th
|
||||
from dominate.tags import thead
|
||||
from dominate.tags import tr
|
||||
from dominate.tags import ul
|
||||
from dominate.util import raw
|
||||
from idf_build_apps import App
|
||||
from idf_build_apps.app import AppDeserializer
|
||||
|
||||
|
||||
def run_idf_build_apps_find(
|
||||
output_file: str = 'apps.json',
|
||||
verbose: bool = False,
|
||||
enable_preview_targets: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Run idf-build-apps find command to get application list
|
||||
|
||||
:param output_file: Output file path
|
||||
:param verbose: Whether to enable verbose output
|
||||
:param enable_preview_targets: Whether to enable preview targets
|
||||
:return: Output file path
|
||||
"""
|
||||
cmd = [
|
||||
'idf-build-apps',
|
||||
'find',
|
||||
'--output-format',
|
||||
'json',
|
||||
'--include-all-apps',
|
||||
'--output',
|
||||
output_file,
|
||||
]
|
||||
|
||||
# Add verbose parameter
|
||||
if verbose:
|
||||
cmd.append('--verbose')
|
||||
|
||||
# Add enable_preview_targets parameter
|
||||
if enable_preview_targets:
|
||||
cmd.append('--enable-preview-targets')
|
||||
|
||||
print(f'Running command: {" ".join(cmd)}')
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
print('Command completed successfully')
|
||||
print(f'stdout: {result.stdout}')
|
||||
if result.stderr:
|
||||
print(f'stderr: {result.stderr}')
|
||||
return output_file
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'Command failed with exit code {e.returncode}')
|
||||
print(f'stdout: {e.stdout}')
|
||||
print(f'stderr: {e.stderr}')
|
||||
raise
|
||||
|
||||
|
||||
def parse_codeowners(codeowners_path: str) -> list[tuple[str, list[str]]]:
|
||||
"""
|
||||
Parse CODEOWNERS file and return a list of (pattern, codeowners) tuples.
|
||||
The list preserves the order of the rules in the file.
|
||||
|
||||
:param codeowners_path: Path to CODEOWNERS file
|
||||
:return: List of (pattern, codeowners) tuples
|
||||
"""
|
||||
codeowners_mapping: list[tuple[str, list[str]]] = []
|
||||
|
||||
if not os.path.exists(codeowners_path):
|
||||
print(f'Warning: CODEOWNERS file not found at {codeowners_path}')
|
||||
return codeowners_mapping
|
||||
|
||||
try:
|
||||
with open(codeowners_path, encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Parse pattern and codeowners
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
print(f'Warning: Invalid CODEOWNERS line {line_num}: {line}')
|
||||
continue
|
||||
|
||||
pattern = parts[0]
|
||||
codeowners = parts[1:]
|
||||
|
||||
# Remove @ symbol and the long prefix from codeowners if present
|
||||
codeowners = [owner.lstrip('@').replace('esp-idf-codeowners/', '') for owner in codeowners]
|
||||
|
||||
codeowners_mapping.append((pattern, codeowners))
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error reading CODEOWNERS file: {e}')
|
||||
|
||||
return codeowners_mapping
|
||||
|
||||
|
||||
def match_codeowners(app_dir: str, codeowners_mapping: list[tuple[str, list[str]]]) -> list[str]:
|
||||
"""
|
||||
Match app directory against CODEOWNERS patterns, supporting wildcards ('*' and '**').
|
||||
The matching traverses up from the app's directory and respects the 'last match wins' rule.
|
||||
|
||||
:param app_dir: Application directory path (e.g., './components/esp_driver_touch_sens/test_apps/touch_sens')
|
||||
:param codeowners_mapping: List of (pattern, codeowners) tuples, in file order
|
||||
:return: List of matching codeowners from the last matching rule
|
||||
"""
|
||||
# 1. Normalize the app_dir path
|
||||
# Remove leading './' and ensure it starts with a single '/'
|
||||
if app_dir.startswith('./'):
|
||||
app_dir = app_dir[1:]
|
||||
if not app_dir.startswith('/'):
|
||||
app_dir = '/' + app_dir
|
||||
app_dir = os.path.normpath(app_dir)
|
||||
|
||||
# 2. Generate all parent paths
|
||||
parent_paths = []
|
||||
current_path = app_dir
|
||||
while True:
|
||||
parent_paths.append(current_path)
|
||||
if current_path == '/':
|
||||
break
|
||||
parent, _ = os.path.split(current_path)
|
||||
if parent == current_path: # Root reached
|
||||
break
|
||||
current_path = parent
|
||||
|
||||
# 3. Iterate through rules in reverse to find the last match
|
||||
for pattern, owners in reversed(codeowners_mapping):
|
||||
# The pattern needs to be anchored to the root for matching
|
||||
if not pattern.startswith('/'):
|
||||
pattern = '/**/' + pattern # Handles patterns like '*.py' or 'docs' anywhere
|
||||
|
||||
for path in parent_paths:
|
||||
if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path + '/', pattern):
|
||||
return owners
|
||||
|
||||
# Fallback to the root-level wildcard rule if no other match is found
|
||||
for pattern, owners in reversed(codeowners_mapping):
|
||||
if pattern == '*':
|
||||
return owners
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def add_codeowners_to_apps(apps: list[App], codeowners_mapping: list[tuple[str, list[str]]]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Match apps to codeowners and return a dictionary mapping app paths to their owners.
|
||||
If an app does not match any rule, it is assigned to the 'others' group.
|
||||
|
||||
:param apps: List of App objects
|
||||
:param codeowners_mapping: List of (pattern, codeowners) tuples, in file order
|
||||
:return: A dictionary mapping app.app_dir to a list of codeowners
|
||||
"""
|
||||
app_to_owners_map = {}
|
||||
for app in apps:
|
||||
matched_owners = match_codeowners(app.app_dir, codeowners_mapping)
|
||||
if not matched_owners:
|
||||
app_to_owners_map[app.app_dir] = ['others']
|
||||
else:
|
||||
app_to_owners_map[app.app_dir] = matched_owners
|
||||
return app_to_owners_map
|
||||
|
||||
|
||||
def load_apps_from_json(json_file: str) -> list[App]:
|
||||
"""
|
||||
Load application list from JSON file (clean version without codeowners)
|
||||
"""
|
||||
apps = []
|
||||
|
||||
with open(json_file, encoding='utf-8') as f:
|
||||
if json_file.endswith('.json'):
|
||||
# If it's JSON format, try to parse as array
|
||||
try:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
for app_data in data:
|
||||
# Handle build_system compatibility: 'idf_cmake' -> 'cmake'
|
||||
if app_data.get('build_system') == 'idf_cmake':
|
||||
app_data['build_system'] = 'cmake'
|
||||
app = AppDeserializer.from_json(json.dumps(app_data))
|
||||
apps.append(app)
|
||||
else:
|
||||
# If not an array, try line-by-line parsing
|
||||
f.seek(0)
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
app = AppDeserializer.from_json(line)
|
||||
apps.append(app)
|
||||
except json.JSONDecodeError:
|
||||
# If JSON parsing fails, try line-by-line parsing
|
||||
f.seek(0)
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
app = AppDeserializer.from_json(line)
|
||||
apps.append(app)
|
||||
except Exception as e:
|
||||
print(f'Warning: Failed to parse line: {line[:100]}... Error: {e}')
|
||||
else:
|
||||
# If not JSON format, parse line by line
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
app = AppDeserializer.from_json(line)
|
||||
apps.append(app)
|
||||
except Exception as e:
|
||||
print(f'Warning: Failed to parse line: {line[:100]}... Error: {e}')
|
||||
|
||||
print(f'Loaded {len(apps)} apps from {json_file}')
|
||||
return apps
|
||||
|
||||
|
||||
def generate_disabled_report(apps: list[App], app_to_owners_map: dict[str, list[str]], report_path: str) -> None:
|
||||
"""Generate disabled report"""
|
||||
# Categorize applications
|
||||
cant_build_temp = []
|
||||
can_build_cant_test_temp = []
|
||||
cant_build_not_temp = []
|
||||
can_build_cant_test_not_temp = []
|
||||
can_test = []
|
||||
all_codeowners: set[str] = set()
|
||||
all_targets: set[str] = set()
|
||||
owner_app_counts: Counter[str] = Counter()
|
||||
target_app_counts: Counter[str] = Counter()
|
||||
|
||||
for app in apps:
|
||||
# Get owners from the map
|
||||
app_owners = app_to_owners_map.get(app.app_dir, [])
|
||||
|
||||
# Update owner app counts
|
||||
for owner in app_owners:
|
||||
owner_app_counts[owner] += 1
|
||||
|
||||
# Update target app counts
|
||||
all_targets.add(app.target)
|
||||
target_app_counts[app.target] += 1
|
||||
|
||||
# Check for unsupported build_status values and abort if found
|
||||
if app.build_status not in ['should be built', 'disabled']:
|
||||
print(
|
||||
f'ERROR: Found unsupported build_status "{app.build_status}" for app {app.app_dir}, target {app.target}'
|
||||
)
|
||||
print(
|
||||
'This task only supports "should be built" and "disabled" status. Please fix the build configuration.'
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Map build_status to the expected status values
|
||||
if app.build_status == 'should be built':
|
||||
if app.test_comment is None:
|
||||
status = 'can_build_and_test'
|
||||
else:
|
||||
status = 'can_build_no_test'
|
||||
else: # if app.build_status == 'disabled':
|
||||
status = 'cannot_build'
|
||||
|
||||
# Categorize apps based on status and temporary flags
|
||||
if status == 'can_build_and_test':
|
||||
can_test.append(app)
|
||||
elif status == 'cannot_build':
|
||||
# Handle cases where build_comment might be None for a disabled app
|
||||
build_comment = app.build_comment or 'Reason not specified'
|
||||
if 'temporary' in build_comment.lower():
|
||||
cant_build_temp.append(app)
|
||||
else:
|
||||
cant_build_not_temp.append(app)
|
||||
elif status == 'can_build_no_test':
|
||||
# Handle cases where test_comment might be None
|
||||
test_comment = app.test_comment or 'Reason not specified'
|
||||
if 'temporary' in test_comment.lower():
|
||||
can_build_cant_test_temp.append(app)
|
||||
else:
|
||||
can_build_cant_test_not_temp.append(app)
|
||||
|
||||
all_codeowners.update(app_owners)
|
||||
|
||||
# Create HTML document
|
||||
doc = document(title='Build and Test Status Report')
|
||||
|
||||
def get_css_styles() -> str:
|
||||
"""Return CSS styles"""
|
||||
return """
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 3px solid #007acc;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #007acc;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #007acc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.navigation {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.navigation h3 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav-link {
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: #005a9e;
|
||||
}
|
||||
.control-buttons {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.control-btn {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 0 10px;
|
||||
font-size: 0.9em;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.control-btn:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
.control-btn.collapse-all {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.control-btn.collapse-all:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
.filters-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
.filter-container {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
#codeownerFilter {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 1em;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
.summary {
|
||||
background-color: #e8f5e8;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #4caf50;
|
||||
}
|
||||
.summary h3 {
|
||||
margin-top: 0;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.summary ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.summary li {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.category-summary {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid #007acc;
|
||||
}
|
||||
.category-summary h3 {
|
||||
margin: 0;
|
||||
color: #007acc;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.category-summary .count {
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.category-details {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.directory-info {
|
||||
background-color: #fff3cd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 3px solid #ffc107;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.directory-info:hover {
|
||||
background-color: #ffeaa7;
|
||||
}
|
||||
.total-count {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.empty-section {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.timestamp {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.table-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.table-header {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #dee2e6;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.table-header:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.table-header h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #495057;
|
||||
}
|
||||
.toggle-icon {
|
||||
font-size: 0.8em;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.table-content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.app-row {
|
||||
display: table-row;
|
||||
}
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.owner-label {
|
||||
margin-left: 10px;
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
}
|
||||
"""
|
||||
|
||||
def create_summary_section() -> None:
|
||||
"""Create summary section"""
|
||||
with div(cls='summary'):
|
||||
h3('Summary')
|
||||
with ul():
|
||||
li(strong('Total apps analyzed: '), str(len(apps)))
|
||||
li(strong('Build temporarily disabled: '), str(len(cant_build_temp)))
|
||||
li(strong('Test temporarily disabled: '), str(len(can_build_cant_test_temp)))
|
||||
li(strong('Build disabled permanently: '), str(len(cant_build_not_temp)))
|
||||
li(strong('Test disabled permanently: '), str(len(can_build_cant_test_not_temp)))
|
||||
li(strong('Normal: '), str(len(can_test)))
|
||||
|
||||
def create_navigation_section() -> None:
|
||||
"""Create navigation section with links to each category"""
|
||||
with div(cls='navigation'):
|
||||
h3('Quick Navigation')
|
||||
with div(cls='nav-links'):
|
||||
for category_id, title, _, _ in categories:
|
||||
a(title, href=f'#{category_id}', cls='nav-link')
|
||||
|
||||
def create_filter_group(
|
||||
title: str,
|
||||
filter_type: str,
|
||||
items: list[str],
|
||||
counts: Counter,
|
||||
total_count: int,
|
||||
) -> None:
|
||||
"""Create a generic filter checkbox group with app counts"""
|
||||
with div(cls='filter-container'):
|
||||
strong(title, style='display: block; margin-bottom: 10px;')
|
||||
with div(id=f'{filter_type}Filter', cls='checkbox-group'):
|
||||
# 'All' checkbox
|
||||
with label(cls='checkbox-label'):
|
||||
input_(
|
||||
type='checkbox',
|
||||
id=f'{filter_type}-all',
|
||||
value='all',
|
||||
onchange=f'toggleAll(this, "{filter_type}")',
|
||||
checked=True,
|
||||
)
|
||||
span(
|
||||
f'All ({total_count})',
|
||||
style='margin-left: 5px; font-weight: bold;',
|
||||
)
|
||||
|
||||
# Individual item checkboxes
|
||||
for item in items:
|
||||
count = counts.get(item, 0)
|
||||
with label(cls='checkbox-label'):
|
||||
input_(
|
||||
type='checkbox',
|
||||
cls=f'{filter_type}-checkbox',
|
||||
value=item,
|
||||
onchange='applyFilters()',
|
||||
checked=True,
|
||||
)
|
||||
span(f'{item} ({count})', style='margin-left: 5px;')
|
||||
|
||||
def create_control_buttons() -> None:
|
||||
"""Create control buttons for expand/collapse all"""
|
||||
with div(cls='control-buttons'):
|
||||
button('Expand All', onclick='expandAll()', cls='control-btn')
|
||||
button('Collapse All', onclick='collapseAll()', cls='control-btn collapse-all')
|
||||
|
||||
def create_category_section(
|
||||
category_id: str,
|
||||
title: str,
|
||||
apps: list[App],
|
||||
all_apps: list[App],
|
||||
show_status: bool = False,
|
||||
) -> None:
|
||||
"""Create category section"""
|
||||
with div(cls='category-summary'):
|
||||
with h3():
|
||||
span(title)
|
||||
span(str(len(apps)), cls='count')
|
||||
|
||||
# Always show section content (no collapse)
|
||||
with div(cls='category-details', id=category_id, style='display: block;'):
|
||||
if apps:
|
||||
# Group by full directory path (preserve last level directory)
|
||||
dir_groups: dict[str, list[App]] = {}
|
||||
for app in apps:
|
||||
# Use full path as grouping key
|
||||
dir_name = app.app_dir
|
||||
if dir_name not in dir_groups:
|
||||
dir_groups[dir_name] = []
|
||||
dir_groups[dir_name].append(app)
|
||||
|
||||
# Calculate total apps for each directory across all categories
|
||||
total_apps_by_dir = {}
|
||||
for app in all_apps:
|
||||
dir_name = app.app_dir
|
||||
if dir_name not in total_apps_by_dir:
|
||||
total_apps_by_dir[dir_name] = 0
|
||||
total_apps_by_dir[dir_name] += 1
|
||||
|
||||
for dir_name, apps_in_dir in dir_groups.items():
|
||||
total_apps = total_apps_by_dir.get(dir_name, len(apps_in_dir))
|
||||
|
||||
# Determine the owners for this directory group
|
||||
dir_owners = set()
|
||||
for app in apps_in_dir:
|
||||
dir_owners.update(app_to_owners_map.get(app.app_dir, []))
|
||||
owners_str = ', '.join(sorted(list(dir_owners)))
|
||||
|
||||
with div(cls='directory-info', onclick=f"toggleTable('{category_id}-{hash(dir_name)}')"):
|
||||
span(
|
||||
'+',
|
||||
cls='toggle-icon',
|
||||
style='float: left; color: #666; margin-right: 8px; font-weight: bold;',
|
||||
)
|
||||
strong(f'{dir_name.replace("./", "")}')
|
||||
span(f' ({len(apps_in_dir)}/{total_apps} apps)', style='color: #666;')
|
||||
span(f'Owners: {owners_str}', cls='owner-label')
|
||||
|
||||
# Table can be collapsed, controlled by table header row
|
||||
with div(cls='table-container'):
|
||||
with div(cls='table-content', id=f'{category_id}-{hash(dir_name)}', style='display: none;'):
|
||||
with table():
|
||||
with thead(cls='table-header'):
|
||||
with tr():
|
||||
th('Target')
|
||||
th('Config')
|
||||
if show_status:
|
||||
th('Status')
|
||||
else:
|
||||
th('Reason')
|
||||
with tbody():
|
||||
for app in apps_in_dir:
|
||||
app_owners = app_to_owners_map.get(app.app_dir, [])
|
||||
with tr(
|
||||
cls='app-row',
|
||||
data_codeowners=','.join(app_owners),
|
||||
):
|
||||
td(app.target)
|
||||
td(app.config_name or '-')
|
||||
if show_status:
|
||||
td('Success')
|
||||
else:
|
||||
reason = (
|
||||
app.build_comment
|
||||
if app.build_status == 'disabled'
|
||||
else app.test_comment
|
||||
)
|
||||
td(reason or 'Reason not specified')
|
||||
else:
|
||||
div('No apps in this category', cls='empty-section')
|
||||
|
||||
def get_javascript() -> str:
|
||||
"""Return JavaScript code"""
|
||||
return """
|
||||
function toggleTable(id) {
|
||||
const content = document.getElementById(id);
|
||||
if (!content) {
|
||||
console.error('Content element not found:', id);
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryInfo = content.parentElement.previousElementSibling;
|
||||
if (!directoryInfo) {
|
||||
console.error('Directory info element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = directoryInfo.querySelector('.toggle-icon');
|
||||
|
||||
if (content.style.display === 'none' || content.style.display === '') {
|
||||
content.style.display = 'block';
|
||||
if (icon) icon.textContent = '-';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
if (icon) icon.textContent = '+';
|
||||
}
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
const allContents = document.querySelectorAll('.table-content');
|
||||
const allIcons = document.querySelectorAll('.toggle-icon');
|
||||
|
||||
allContents.forEach(content => {
|
||||
content.style.display = 'block';
|
||||
});
|
||||
|
||||
allIcons.forEach(icon => {
|
||||
icon.textContent = '-';
|
||||
});
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
const allContents = document.querySelectorAll('.table-content');
|
||||
const allIcons = document.querySelectorAll('.toggle-icon');
|
||||
|
||||
allContents.forEach(content => {
|
||||
content.style.display = 'none';
|
||||
});
|
||||
|
||||
allIcons.forEach(icon => {
|
||||
icon.textContent = '+';
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
// Get selected codeowners
|
||||
const selectedCodeowners = Array.from(
|
||||
document.querySelectorAll('.codeowner-checkbox:checked')
|
||||
).map(cb => cb.value);
|
||||
|
||||
// Get selected targets
|
||||
const selectedTargets = Array.from(
|
||||
document.querySelectorAll('.target-checkbox:checked')
|
||||
).map(cb => cb.value);
|
||||
|
||||
// Update 'All' checkbox states
|
||||
const allCodeownerCheckbox = document.getElementById('codeowner-all');
|
||||
const allCodeownerCheckboxes = document.querySelectorAll('.codeowner-checkbox');
|
||||
allCodeownerCheckbox.checked = selectedCodeowners.length === allCodeownerCheckboxes.length;
|
||||
|
||||
const allTargetCheckbox = document.getElementById('target-all');
|
||||
const allTargetCheckboxes = document.querySelectorAll('.target-checkbox');
|
||||
allTargetCheckbox.checked = selectedTargets.length === allTargetCheckboxes.length;
|
||||
|
||||
// Filter rows and hide empty directory sections
|
||||
const directorySections = document.querySelectorAll('.directory-info');
|
||||
directorySections.forEach(section => {
|
||||
const tableContainer = section.nextElementSibling;
|
||||
if (!tableContainer) return;
|
||||
|
||||
const rows = tableContainer.querySelectorAll('.app-row');
|
||||
let visibleRows = 0;
|
||||
rows.forEach(row => {
|
||||
const codeowners = row.getAttribute('data-codeowners').split(',');
|
||||
// Assumes target is the first cell
|
||||
const target = row.querySelector('td:first-child').textContent;
|
||||
|
||||
const codeownerMatch = selectedCodeowners.some(owner => codeowners.includes(owner));
|
||||
const targetMatch = selectedTargets.includes(target);
|
||||
|
||||
if (codeownerMatch && targetMatch) {
|
||||
row.style.display = 'table-row';
|
||||
visibleRows++;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide the entire directory section if no rows are visible
|
||||
if (visibleRows === 0) {
|
||||
section.style.display = 'none';
|
||||
tableContainer.style.display = 'none';
|
||||
} else {
|
||||
section.style.display = 'block';
|
||||
tableContainer.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll(checkbox, filterType) {
|
||||
const allCheckboxes = document.querySelectorAll(`.${filterType}-checkbox`);
|
||||
allCheckboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
applyFilters(); // Apply filters immediately
|
||||
}
|
||||
|
||||
// Initial filter call to show all rows at the beginning
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyFilters();
|
||||
});
|
||||
"""
|
||||
|
||||
# Define category configuration
|
||||
categories = [
|
||||
('cant-build-temp', '1. Build temporarily disabled', cant_build_temp, False),
|
||||
('can-build-cant-test-temp', '2. Test temporarily disabled', can_build_cant_test_temp, False),
|
||||
('cant-build-not-temp', '3. Build disabled permanently', cant_build_not_temp, False),
|
||||
('can-build-cant-test-not_temp', '4. Test disabled permanently', can_build_cant_test_not_temp, False),
|
||||
('can-test', '5. Normal', can_test, True),
|
||||
]
|
||||
|
||||
with doc.head:
|
||||
style(get_css_styles())
|
||||
script(raw(get_javascript()))
|
||||
|
||||
with doc:
|
||||
with div(cls='container'):
|
||||
h1('Build and Test Status Report')
|
||||
div(f'Generated at: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}', cls='timestamp')
|
||||
|
||||
# Create summary section
|
||||
create_summary_section()
|
||||
|
||||
# Create navigation section
|
||||
create_navigation_section()
|
||||
|
||||
# Create filter section and pass owner counts
|
||||
with div(cls='filters-wrapper'):
|
||||
create_filter_group(
|
||||
'Filter by Codeowner:',
|
||||
'codeowner',
|
||||
sorted(list(all_codeowners)),
|
||||
owner_app_counts,
|
||||
len(apps),
|
||||
)
|
||||
create_filter_group(
|
||||
'Filter by Target:',
|
||||
'target',
|
||||
sorted(list(all_targets)),
|
||||
target_app_counts,
|
||||
len(apps),
|
||||
)
|
||||
|
||||
# Create control buttons
|
||||
create_control_buttons()
|
||||
|
||||
# Generate each category using configuration
|
||||
for category_id, title, apps_list, show_status in categories:
|
||||
create_category_section(category_id, title, apps_list, apps, show_status)
|
||||
|
||||
# Write to file
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(doc.render())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Generate a report of disabled and skipped builds/tests',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Basic usage (uses default paths: examples, tools/test_apps, components)
|
||||
python gen_disabled_report.py --output report.html
|
||||
|
||||
# Use existing JSON file
|
||||
python gen_disabled_report.py --input apps.json --output report.html
|
||||
|
||||
# Enable verbose output
|
||||
python gen_disabled_report.py --input apps.json --output report.html --verbose
|
||||
""",
|
||||
)
|
||||
|
||||
# Input options
|
||||
parser.add_argument('--input', type=str, help='Input JSON file containing apps data')
|
||||
|
||||
# Output options
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=str,
|
||||
default='disabled_report.html',
|
||||
help='Output report file path (default: disabled_report.html)',
|
||||
)
|
||||
|
||||
# idf-build-apps find options
|
||||
parser.add_argument(
|
||||
'--temp-json',
|
||||
type=str,
|
||||
default='apps.json',
|
||||
help='Temporary JSON file path for idf-build-apps find output (default: apps.json)',
|
||||
)
|
||||
parser.add_argument('--verbose', action='store_true', help='Enable verbose output')
|
||||
parser.add_argument('--enable-preview-targets', action='store_true', help='Enable preview targets')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Always run idf-build-apps find to get the most up-to-date data
|
||||
print('Running idf-build-apps find to generate app list...')
|
||||
input_file = run_idf_build_apps_find(
|
||||
output_file=args.temp_json,
|
||||
verbose=args.verbose,
|
||||
enable_preview_targets=args.enable_preview_targets,
|
||||
)
|
||||
|
||||
# Load application data
|
||||
print(f'Loading apps from {input_file}...')
|
||||
apps = load_apps_from_json(input_file)
|
||||
|
||||
# Add codeowners information
|
||||
print('Adding codeowners information...')
|
||||
idf_path = os.environ.get('IDF_PATH')
|
||||
if not idf_path:
|
||||
raise ValueError('IDF_PATH environment variable is not set')
|
||||
codeowners_path = os.path.join(idf_path, '.gitlab', 'CODEOWNERS')
|
||||
codeowners_mapping = parse_codeowners(codeowners_path)
|
||||
app_to_owners_map = add_codeowners_to_apps(apps, codeowners_mapping)
|
||||
|
||||
# Generate report
|
||||
print(f'Generating report to {args.output}...')
|
||||
generate_disabled_report(apps, app_to_owners_map, args.output)
|
||||
|
||||
print(f'Report generated successfully: {args.output}')
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error: {e}')
|
||||
if args.verbose:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
Reference in New Issue
Block a user