#!/usr/bin/env python3 """Merge-friendly ACK task records. ``tasks.yaml`` remains the small project manifest. When it contains a ``taskStore`` mapping, task state is materialized from immutable record files under the configured directory. Legacy boards without that mapping keep their original single-file behavior. Records are complete task snapshots rather than patches. A later record names its parent record(s), so a concurrent edit produces multiple heads and is reported as a semantic merge conflict instead of being silently resolved by file order or timestamps. """ from __future__ import annotations import json import re from pathlib import Path from typing import Any from yaml_subset import ( DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader, ) STORE_VERSION = 1 GLOBAL_TASK_ID = "__project__" TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$") RECORD_SUFFIXES = {".yaml", ".yml", ".json"} class TaskStoreError(ValueError): """A malformed task store or record.""" def parse_document(path: Path) -> dict[str, Any]: """Parse one YAML/JSON mapping without importing the validator.""" try: content = path.read_text(encoding="utf-8") except OSError as exc: raise TaskStoreError(f"读取失败: {path}: {exc}") from exc try: if path.suffix.lower() == ".json": value = load_json_unique(content) else: try: import yaml # type: ignore except ImportError: value = load_yaml_subset(content) else: value = yaml.load( content, Loader=make_unique_pyyaml_loader(yaml), ) except (json.JSONDecodeError, DuplicateKeyError, YamlSubsetError) as exc: raise TaskStoreError(f"解析失败: {path}: {exc}") from exc except Exception as exc: # PyYAML errors are intentionally bounded. raise TaskStoreError(f"解析失败: {path}") from exc if not isinstance(value, dict): raise TaskStoreError(f"{path}: 顶层必须是对象(mapping)") return value def _record_directory(manifest: dict[str, Any], manifest_path: Path) -> Path: config = manifest.get("taskStore") if not isinstance(config, dict): raise TaskStoreError("taskStore 必须是对象") version = config.get("version", STORE_VERSION) if version != STORE_VERSION: raise TaskStoreError(f"不支持 taskStore.version={version!r}") directory = config.get("directory", "task-records") if not isinstance(directory, str) or not directory.strip(): raise TaskStoreError("taskStore.directory 必须是非空相对路径") relative = Path(directory) if relative.is_absolute() or ".." in relative.parts: raise TaskStoreError("taskStore.directory 必须位于任务板目录下") return manifest_path.parent / relative def _record_paths(directory: Path) -> list[Path]: if not directory.exists(): return [] if directory.is_symlink() or not directory.is_dir(): raise TaskStoreError(f"taskStore.directory 不是普通目录: {directory}") paths: list[Path] = [] for path in sorted(directory.iterdir(), key=lambda item: item.name): if path.name.startswith("."): continue if path.is_symlink() or not path.is_file(): raise TaskStoreError(f"任务记录必须是普通文件: {path}") if path.suffix.lower() in RECORD_SUFFIXES: paths.append(path) return paths def _unique_collection( records: list[dict[str, Any]], field: str, errors: list[str], ) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] by_id: dict[str, dict[str, Any]] = {} for record in records: values = record.get(field) if values is None: continue if not isinstance(values, list): errors.append(f"{field} 必须是列表") continue for index, item in enumerate(values): if not isinstance(item, dict): errors.append(f"{field}[{index}] 必须是对象") continue item_id = item.get("id") if not isinstance(item_id, str) or not item_id: errors.append(f"{field}[{index}].id 必须是非空字符串") continue previous = by_id.get(item_id) if previous is not None and previous != item: errors.append(f"{field} 出现相同 id 的不同记录: {item_id}") continue if previous is None: by_id[item_id] = item result.append(item) return result def materialize_board(manifest: dict[str, Any], manifest_path: Path) -> dict[str, Any]: """Return a board-shaped view from a manifest and immutable records.""" directory = _record_directory(manifest, manifest_path) paths = _record_paths(directory) # This permits a staged rollout: a freshly initialized board has an empty # store and the legacy empty ``tasks: []`` field until its first record. if not paths: return manifest errors: list[str] = [] records: list[dict[str, Any]] = [] by_record_id: dict[str, dict[str, Any]] = {} for path in paths: record = parse_document(path) where = str(path.relative_to(manifest_path.parent)) version = record.get("recordVersion", STORE_VERSION) record_id = record.get("recordId") task_id = record.get("taskId") parents = record.get("parents", []) if version != STORE_VERSION: errors.append(f"{where}: 不支持 recordVersion={version!r}") if not isinstance(record_id, str) or RECORD_ID_RE.fullmatch(record_id) is None: errors.append(f"{where}: recordId 格式无效") elif record_id in by_record_id: errors.append(f"{where}: recordId 重复: {record_id}") if not isinstance(task_id, str) or ( task_id != GLOBAL_TASK_ID and TASK_ID_RE.fullmatch(task_id) is None ): errors.append(f"{where}: taskId 格式无效") if not isinstance(parents, list) or any( not isinstance(parent, str) or RECORD_ID_RE.fullmatch(parent) is None for parent in parents ): errors.append(f"{where}: parents 必须是 recordId 字符串列表") task = record.get("task") if task_id != GLOBAL_TASK_ID and not isinstance(task, dict): errors.append(f"{where}: 非全局记录必须包含 task 对象") if isinstance(record_id, str) and record_id not in by_record_id: by_record_id[record_id] = record records.append(record) for record in records: record_id = record.get("recordId") task_id = record.get("taskId") if not isinstance(record_id, str) or not isinstance(task_id, str): continue for parent in record.get("parents", []): if parent not in by_record_id: errors.append(f"{record_id}: 引用了不存在的 parent: {parent}") elif by_record_id[parent].get("taskId") != task_id: errors.append(f"{record_id}: parent 必须属于同一个 taskId") task_records: dict[str, list[dict[str, Any]]] = {} for record in records: task_id = record.get("taskId") if isinstance(task_id, str) and task_id != GLOBAL_TASK_ID: task_records.setdefault(task_id, []).append(record) tasks: list[dict[str, Any]] = [] for task_id in sorted(task_records): items = task_records[task_id] referenced = { parent for item in items for parent in item.get("parents", []) if isinstance(parent, str) } heads = [ item for item in items if isinstance(item.get("recordId"), str) and item["recordId"] not in referenced ] if len(heads) != 1: errors.append( f"task {task_id}: 发现 {len(heads)} 个未合并 head,必须创建同时引用全部 head 的合并记录" ) continue task = heads[0].get("task") if isinstance(task, dict): tasks.append(task) result = dict(manifest) result["tasks"] = tasks result["workerReceipts"] = _unique_collection(records, "workerReceipts", errors) for field in ("deliveryRuns", "regressionRuns"): if field in manifest or any(field in record for record in records): result[field] = _unique_collection(records, field, errors) result["summary"] = { "open": [task["id"] for task in tasks if task.get("status") == "open"], "verified": [task["id"] for task in tasks if task.get("status") == "verified"], "failedRetest": [task["id"] for task in tasks if task.get("status") == "failed_retest"], "leftovers": [task["id"] for task in tasks if task.get("status") == "leftover"], } if errors: result["taskStoreErrors"] = errors else: result.pop("taskStoreErrors", None) return result def is_task_store_manifest(value: object) -> bool: return isinstance(value, dict) and "taskStore" in value __all__ = [ "GLOBAL_TASK_ID", "STORE_VERSION", "TaskStoreError", "is_task_store_manifest", "materialize_board", "parse_document", ]