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
|
- artifact: build_child_pipeline.yml
|
||||||
job: generate_build_child_pipeline
|
job: generate_build_child_pipeline
|
||||||
strategy: depend
|
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/envsubst.py
|
||||||
tools/ci/executable-list.txt
|
tools/ci/executable-list.txt
|
||||||
tools/ci/fix_empty_prototypes.sh
|
tools/ci/fix_empty_prototypes.sh
|
||||||
|
tools/ci/gen_disabled_report.py
|
||||||
tools/ci/generate_rules.py
|
tools/ci/generate_rules.py
|
||||||
tools/ci/get-full-sources.sh
|
tools/ci/get-full-sources.sh
|
||||||
tools/ci/get_all_test_results.py
|
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