Tree fixes
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ISSUE_TYPES = {"addressed", "known"}
|
||||||
|
FILENAME_RE = re.compile(r"^(?P<version>[^_]+)_(?P<date>\d{4}-\d{2}-\d{2})\.[^.]+$")
|
||||||
|
HOTFIX_RE = re.compile(r"^(?P<base>\d+(?:\.\d+)*)(?:-h(?P<hotfix>\d+))?$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileIdentity:
|
||||||
|
product: str
|
||||||
|
issue_type: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileRecord:
|
||||||
|
identity: FileIdentity
|
||||||
|
filename: str
|
||||||
|
release_date: date
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Update products.json from issue files, keeping latest date per version."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--issues-dir",
|
||||||
|
default="web/data/issues",
|
||||||
|
help="Directory containing product issue files.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--products-json",
|
||||||
|
default="web/data/products.json",
|
||||||
|
help="Path to products.json.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_latest_issue_files(issues_dir: Path) -> dict[FileIdentity, FileRecord]:
|
||||||
|
latest: dict[FileIdentity, FileRecord] = {}
|
||||||
|
|
||||||
|
for file_path in sorted(p for p in issues_dir.rglob("*") if p.is_file()):
|
||||||
|
relative = file_path.relative_to(issues_dir)
|
||||||
|
if len(relative.parts) != 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
product, issue_type, filename = relative.parts
|
||||||
|
if issue_type not in ISSUE_TYPES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = parse_issue_filename(filename)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
version, release_date = parsed
|
||||||
|
identity = FileIdentity(product=product, issue_type=issue_type, version=version)
|
||||||
|
record = FileRecord(identity=identity, filename=filename, release_date=release_date)
|
||||||
|
|
||||||
|
current = latest.get(identity)
|
||||||
|
if current is None or (record.release_date, record.filename) > (
|
||||||
|
current.release_date,
|
||||||
|
current.filename,
|
||||||
|
):
|
||||||
|
latest[identity] = record
|
||||||
|
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def parse_issue_filename(filename: str) -> tuple[str, date] | None:
|
||||||
|
match = FILENAME_RE.match(filename)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = match.group("version")
|
||||||
|
try:
|
||||||
|
release_date = date.fromisoformat(match.group("date"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return version, release_date
|
||||||
|
|
||||||
|
|
||||||
|
def clear_leaf_issue_arrays(node: Any) -> None:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
if "addressed" in node or "known" in node:
|
||||||
|
node["addressed"] = []
|
||||||
|
node["known"] = []
|
||||||
|
return
|
||||||
|
|
||||||
|
for value in node.values():
|
||||||
|
clear_leaf_issue_arrays(value)
|
||||||
|
|
||||||
|
|
||||||
|
def is_issue_leaf(node: Any) -> bool:
|
||||||
|
return isinstance(node, dict) and ("addressed" in node or "known" in node)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_target_leaf_path(products: dict[str, Any], product: str, version: str) -> list[str] | None:
|
||||||
|
parts = version.split(".")
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
major = parts[0]
|
||||||
|
minor = f"{parts[0]}.{parts[1]}" if len(parts) > 1 else parts[0]
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
[product, major, minor],
|
||||||
|
[product, major],
|
||||||
|
[product],
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in candidates:
|
||||||
|
node = get_path(products, path)
|
||||||
|
if is_issue_leaf(node):
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_path(data: Any, path: list[str]) -> Any:
|
||||||
|
current = data
|
||||||
|
for key in path:
|
||||||
|
if not isinstance(current, dict) or key not in current:
|
||||||
|
return None
|
||||||
|
current = current[key]
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]:
|
||||||
|
match = HOTFIX_RE.match(prefix)
|
||||||
|
if not match:
|
||||||
|
return tuple(), -1
|
||||||
|
|
||||||
|
base_parts = tuple(int(part) for part in match.group("base").split("."))
|
||||||
|
hotfix_group = match.group("hotfix")
|
||||||
|
hotfix = int(hotfix_group) if hotfix_group is not None else -1
|
||||||
|
return base_parts, hotfix
|
||||||
|
|
||||||
|
|
||||||
|
def filename_sort_key(filename: str) -> tuple[tuple[int, ...], int, int, str]:
|
||||||
|
prefix = filename.split("_", 1)[0]
|
||||||
|
base_parts, hotfix = parse_release_prefix(prefix)
|
||||||
|
is_hotfix = 1 if hotfix >= 0 else 0
|
||||||
|
return base_parts, is_hotfix, hotfix, filename
|
||||||
|
|
||||||
|
|
||||||
|
def add_file_to_leaf(products: dict[str, Any], path: list[str], issue_type: str, filename: str) -> None:
|
||||||
|
leaf = get_path(products, path)
|
||||||
|
if not isinstance(leaf, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
files = leaf.get(issue_type)
|
||||||
|
if not isinstance(files, list):
|
||||||
|
files = []
|
||||||
|
|
||||||
|
files.append(filename)
|
||||||
|
leaf[issue_type] = sorted(set(files), key=filename_sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
def update_products(issues_dir: Path, products_path: Path) -> None:
|
||||||
|
if not issues_dir.is_dir():
|
||||||
|
raise FileNotFoundError(f"Issues directory not found: {issues_dir}")
|
||||||
|
if not products_path.is_file():
|
||||||
|
raise FileNotFoundError(f"products.json not found: {products_path}")
|
||||||
|
|
||||||
|
with products_path.open("r", encoding="utf-8") as f:
|
||||||
|
products = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(products, dict):
|
||||||
|
raise ValueError("products.json must contain a top-level object")
|
||||||
|
|
||||||
|
clear_leaf_issue_arrays(products)
|
||||||
|
|
||||||
|
latest_files = collect_latest_issue_files(issues_dir)
|
||||||
|
for record in sorted(latest_files.values(), key=lambda r: (r.identity.product, r.identity.issue_type, r.identity.version)):
|
||||||
|
path = pick_target_leaf_path(products, record.identity.product, record.identity.version)
|
||||||
|
if path is None:
|
||||||
|
continue
|
||||||
|
add_file_to_leaf(products, path, record.identity.issue_type, record.filename)
|
||||||
|
|
||||||
|
with products_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(products, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
repo_root = Path(__file__).resolve().parent
|
||||||
|
issues_dir = (repo_root / args.issues_dir).resolve()
|
||||||
|
products_path = (repo_root / args.products_json).resolve()
|
||||||
|
|
||||||
|
update_products(issues_dir=issues_dir, products_path=products_path)
|
||||||
|
print(f"Updated {products_path} from {issues_dir}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -110,4 +110,4 @@
|
|||||||
"addressed": [],
|
"addressed": [],
|
||||||
"known": []
|
"known": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ function createTreeNode(name, value, path) {
|
|||||||
childrenContainer.appendChild(childNode);
|
childrenContainer.appendChild(childNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldStartCollapsed(name, value)) {
|
||||||
|
childrenContainer.classList.add('collapsed');
|
||||||
|
toggle.textContent = '▶';
|
||||||
|
}
|
||||||
|
|
||||||
toggle.addEventListener('click', event => {
|
toggle.addEventListener('click', event => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
childrenContainer.classList.toggle('collapsed');
|
childrenContainer.classList.toggle('collapsed');
|
||||||
@@ -138,6 +143,24 @@ function createTreeNode(name, value, path) {
|
|||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldStartCollapsed(name, value) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childKeys = Object.keys(value);
|
||||||
|
if (childKeys.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotfixChildren = childKeys.filter(key => {
|
||||||
|
const match = key.match(/^(.*)-h\d+$/i);
|
||||||
|
return match && match[1] === name;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hotfixChildren.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function createCheckbox(path, value) {
|
function createCheckbox(path, value) {
|
||||||
const checkbox = document.createElement('input');
|
const checkbox = document.createElement('input');
|
||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
|
|||||||
+44
-8
@@ -56,25 +56,25 @@ function transformNode(node) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = {};
|
const groupedByPrefix = {};
|
||||||
|
|
||||||
addressed.forEach(fileName => {
|
addressed.forEach(fileName => {
|
||||||
const prefix = getFilePrefix(fileName);
|
const prefix = getFilePrefix(fileName);
|
||||||
if (!grouped[prefix]) {
|
if (!groupedByPrefix[prefix]) {
|
||||||
grouped[prefix] = { addressed: [], known: [] };
|
groupedByPrefix[prefix] = { addressed: [], known: [] };
|
||||||
}
|
}
|
||||||
grouped[prefix].addressed.push(fileName);
|
groupedByPrefix[prefix].addressed.push(fileName);
|
||||||
});
|
});
|
||||||
|
|
||||||
known.forEach(fileName => {
|
known.forEach(fileName => {
|
||||||
const prefix = getFilePrefix(fileName);
|
const prefix = getFilePrefix(fileName);
|
||||||
if (!grouped[prefix]) {
|
if (!groupedByPrefix[prefix]) {
|
||||||
grouped[prefix] = { addressed: [], known: [] };
|
groupedByPrefix[prefix] = { addressed: [], known: [] };
|
||||||
}
|
}
|
||||||
grouped[prefix].known.push(fileName);
|
groupedByPrefix[prefix].known.push(fileName);
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return groupHotfixPrefixes(groupedByPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformed = {};
|
const transformed = {};
|
||||||
@@ -84,6 +84,42 @@ function transformNode(node) {
|
|||||||
return transformed;
|
return transformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupHotfixPrefixes(groupedByPrefix) {
|
||||||
|
const byFamily = {};
|
||||||
|
|
||||||
|
Object.keys(groupedByPrefix).forEach(prefix => {
|
||||||
|
const family = getReleaseFamily(prefix);
|
||||||
|
if (!byFamily[family]) {
|
||||||
|
byFamily[family] = [];
|
||||||
|
}
|
||||||
|
byFamily[family].push(prefix);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
Object.keys(byFamily).forEach(family => {
|
||||||
|
const members = byFamily[family];
|
||||||
|
const hasHotfixes = members.length > 1 || members.some(member => member !== family);
|
||||||
|
|
||||||
|
if (!hasHotfixes && members[0] === family) {
|
||||||
|
result[family] = groupedByPrefix[family];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = {};
|
||||||
|
members.forEach(member => {
|
||||||
|
branch[member] = groupedByPrefix[member];
|
||||||
|
});
|
||||||
|
result[family] = branch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReleaseFamily(prefix) {
|
||||||
|
return String(prefix || '').replace(/-h\d+$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
function getFilePrefix(fileName) {
|
function getFilePrefix(fileName) {
|
||||||
const baseName = String(fileName || '');
|
const baseName = String(fileName || '');
|
||||||
const underscoreIndex = baseName.indexOf('_');
|
const underscoreIndex = baseName.indexOf('_');
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
flex: 0 0 250px;
|
flex: 0 0 350px;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user