250 lines
9.3 KiB
Python
250 lines
9.3 KiB
Python
#!/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",
|
||
]
|