156 lines
5.9 KiB
Python
156 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Migrate a legacy ACK tasks.yaml into immutable task records.
|
|
|
|
The default is a read-only dry run. ``--apply`` writes a sibling
|
|
``tasks.legacy.yaml`` backup, creates one initial record per task, and replaces
|
|
the task board with a small manifest that points at ``task-records/``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from task_store import GLOBAL_TASK_ID, STORE_VERSION, TaskStoreError, parse_document
|
|
|
|
|
|
def _atomic_write(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
|
try:
|
|
temporary.write_text(content, encoding="utf-8")
|
|
os.replace(temporary, path)
|
|
finally:
|
|
if temporary.exists():
|
|
temporary.unlink()
|
|
|
|
|
|
def _record_id(task_id: str) -> str:
|
|
return f"EV-{task_id}-initial"
|
|
|
|
|
|
def _record(task_id: str, task: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
value: dict[str, Any] = {
|
|
"recordVersion": STORE_VERSION,
|
|
"recordId": _record_id(task_id),
|
|
"taskId": task_id,
|
|
"parents": [],
|
|
"kind": "task.snapshot",
|
|
}
|
|
if task is not None:
|
|
value["task"] = task
|
|
return value
|
|
|
|
|
|
def build_records(board: dict[str, Any]) -> list[dict[str, Any]]:
|
|
tasks = board.get("tasks")
|
|
if not isinstance(tasks, list):
|
|
raise TaskStoreError("legacy tasks 必须是列表")
|
|
records: list[dict[str, Any]] = []
|
|
task_ids: set[str] = set()
|
|
for task in tasks:
|
|
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
|
|
raise TaskStoreError("每条 legacy task 必须包含字符串 id")
|
|
task_id = task["id"]
|
|
if task_id in task_ids:
|
|
raise TaskStoreError(f"task id 重复: {task_id}")
|
|
task_ids.add(task_id)
|
|
records.append(_record(task_id, dict(task)))
|
|
|
|
receipts = board.get("workerReceipts") or []
|
|
if not isinstance(receipts, list):
|
|
raise TaskStoreError("workerReceipts 必须是列表")
|
|
by_task: dict[str, list[dict[str, Any]]] = {task_id: [] for task_id in task_ids}
|
|
global_receipts: list[dict[str, Any]] = []
|
|
for receipt in receipts:
|
|
if not isinstance(receipt, dict):
|
|
raise TaskStoreError("workerReceipts 每项必须是对象")
|
|
created_for = receipt.get("createdFor")
|
|
task_id = created_for.get("taskId") if isinstance(created_for, dict) else None
|
|
if isinstance(task_id, str) and task_id in by_task:
|
|
by_task[task_id].append(receipt)
|
|
else:
|
|
global_receipts.append(receipt)
|
|
for record in records:
|
|
task_id = record["taskId"]
|
|
if by_task[task_id]:
|
|
record["workerReceipts"] = by_task[task_id]
|
|
|
|
global_record = _record(GLOBAL_TASK_ID)
|
|
if global_receipts:
|
|
global_record["workerReceipts"] = global_receipts
|
|
for field in ("deliveryRuns", "regressionRuns"):
|
|
value = board.get(field)
|
|
if value is not None:
|
|
if not isinstance(value, list):
|
|
raise TaskStoreError(f"{field} 必须是列表")
|
|
global_record[field] = value
|
|
if any(field in global_record for field in ("workerReceipts", "deliveryRuns", "regressionRuns")):
|
|
records.append(global_record)
|
|
return records
|
|
|
|
|
|
def migrate(path: Path, *, apply: bool, directory: str | None) -> dict[str, Any]:
|
|
board = parse_document(path)
|
|
if "taskStore" in board:
|
|
raise TaskStoreError("任务板已经启用 taskStore,无需重复迁移")
|
|
records = build_records(board)
|
|
record_directory = directory or "task-records"
|
|
relative = Path(record_directory)
|
|
if relative.is_absolute() or ".." in relative.parts:
|
|
raise TaskStoreError("records directory 必须是任务板目录下的相对路径")
|
|
output_dir = path.parent / relative
|
|
backup = path.with_name(f"{path.stem}.legacy{path.suffix}")
|
|
if apply and backup.exists():
|
|
raise TaskStoreError(f"备份文件已存在,拒绝覆盖: {backup}")
|
|
|
|
manifest = dict(board)
|
|
for field in ("summary", "workerReceipts", "deliveryRuns", "regressionRuns"):
|
|
manifest.pop(field, None)
|
|
manifest["tasks"] = []
|
|
manifest["taskStore"] = {"version": STORE_VERSION, "directory": record_directory}
|
|
|
|
if apply:
|
|
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, backup)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
for record in records:
|
|
record_path = output_dir / f"{record['recordId']}.yaml"
|
|
if record_path.exists():
|
|
raise TaskStoreError(f"记录文件已存在,拒绝覆盖: {record_path}")
|
|
_atomic_write(record_path, json.dumps(record, ensure_ascii=False, indent=2) + "\n")
|
|
_atomic_write(path, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
|
|
|
return {
|
|
"mode": "apply" if apply else "dry-run",
|
|
"tasks": len([record for record in records if record.get("taskId") != GLOBAL_TASK_ID]),
|
|
"records": len(records),
|
|
"recordsDirectory": str(output_dir),
|
|
"backup": str(backup),
|
|
"taskStore": manifest["taskStore"],
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("tasks", nargs="?", default=".pouch/ack/tasks.yaml")
|
|
parser.add_argument("--apply", action="store_true", help="写入记录并替换任务板")
|
|
parser.add_argument("--records-dir", help="任务记录目录,默认 task-records")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
result = migrate(Path(args.tasks), apply=args.apply, directory=args.records_dir)
|
|
except (OSError, TaskStoreError) as exc:
|
|
print(f"迁移失败: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|