#!/usr/bin/env python3 """为 ACK Coordinator 输出有预算的任务板上下文。 脚本会解析并校验完整 tasks.yaml,但只输出项目配置、摘要、可工作任务或显式任务, 以及这些任务引用的 worker receipt 和 delivery run。它不会修改任务板,也不会静默 截断超过预算的任务集合。 """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any from validate_tasks import STATUS_ENUM, load_document, validate_builtin # type: ignore ACTIONABLE_STATUSES = ( "open", "dispatched", "fixed_by_dev", "retesting", "failed_retest", ) DEFAULT_LIMIT = 20 MAX_LIMIT = 100 MAX_OUTPUT_BYTES = 512 * 1024 BOARD_METADATA_FIELDS = ( "version", "updatedAt", "source", "ackVersion", "kitVersion", "testRecord", "statusReference", ) class SelectionError(ValueError): pass def _unique(values: list[str]) -> list[str]: return list(dict.fromkeys(values)) def select_tasks( data: dict[str, Any], *, task_ids: list[str] | None = None, statuses: list[str] | None = None, limit: int = DEFAULT_LIMIT, ) -> tuple[str, list[str], list[dict[str, Any]]]: if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_LIMIT: raise SelectionError(f"limit 必须在 1..{MAX_LIMIT} 之间") tasks = data.get("tasks") if not isinstance(tasks, list): raise SelectionError("tasks 必须是列表") requested_ids = _unique(task_ids or []) requested_statuses = _unique(statuses or []) if requested_ids and requested_statuses: raise SelectionError("--task-id 与 --status 不能同时使用") if requested_ids: by_id = { task.get("id"): task for task in tasks if isinstance(task, dict) and isinstance(task.get("id"), str) } missing = [task_id for task_id in requested_ids if task_id not in by_id] if missing: raise SelectionError("找不到任务: " + ", ".join(missing)) selected = [by_id[task_id] for task_id in requested_ids] mode = "task_ids" criteria = requested_ids else: effective_statuses = requested_statuses or list(ACTIONABLE_STATUSES) unknown = [status for status in effective_statuses if status not in STATUS_ENUM] if unknown: raise SelectionError("未知状态: " + ", ".join(unknown)) wanted = set(effective_statuses) selected = [ task for task in tasks if isinstance(task, dict) and task.get("status") in wanted ] mode = "statuses" criteria = effective_statuses if len(selected) > limit: raise SelectionError( f"命中 {len(selected)} 条任务,超过 --limit={limit};" "请用 --task-id/--status 缩小范围或显式提高 limit" ) return mode, criteria, selected def select_referenced_receipts( data: dict[str, Any], selected: list[dict[str, Any]] ) -> list[dict[str, Any]]: receipt_ids: list[str] = [] for task in selected: dispatch = task.get("dispatch") if not isinstance(dispatch, dict): continue for role in ("developer", "test"): role_dispatch = dispatch.get(role) if not isinstance(role_dispatch, dict): continue receipt_id = role_dispatch.get("receiptId") if isinstance(receipt_id, str) and receipt_id not in receipt_ids: receipt_ids.append(receipt_id) if not receipt_ids: return [] receipts = data.get("workerReceipts") if not isinstance(receipts, list): raise SelectionError("选中任务引用了 receipt,但 workerReceipts 不是列表") by_id = { receipt.get("id"): receipt for receipt in receipts if isinstance(receipt, dict) and isinstance(receipt.get("id"), str) } missing = [receipt_id for receipt_id in receipt_ids if receipt_id not in by_id] if missing: raise SelectionError("选中任务引用了未知 receipt: " + ", ".join(missing)) return [by_id[receipt_id] for receipt_id in receipt_ids] def select_delivery_runs( data: dict[str, Any], selected: list[dict[str, Any]] ) -> list[dict[str, Any]]: selected_ids = { task.get("id") for task in selected if isinstance(task.get("id"), str) } runs = data.get("deliveryRuns") if not selected_ids or not isinstance(runs, list): return [] return [ run for run in runs if isinstance(run, dict) and isinstance(run.get("taskIds"), list) and any(task_id in selected_ids for task_id in run["taskIds"]) ] def build_payload( data: dict[str, Any], *, mode: str, criteria: list[str], selected: list[dict[str, Any]], ) -> dict[str, Any]: receipts = select_referenced_receipts(data, selected) delivery_runs = select_delivery_runs(data, selected) tasks = data.get("tasks") metadata = { field: data[field] for field in BOARD_METADATA_FIELDS if field in data } selected_ids = [ task["id"] for task in selected if isinstance(task.get("id"), str) ] payload: dict[str, Any] = { "board": metadata, "project": data.get("project"), "summary": data.get("summary"), "selection": { "mode": mode, "criteria": criteria, "count": len(selected), "totalTasks": len(tasks) if isinstance(tasks, list) else 0, "selectedTaskIds": selected_ids, "referencedReceiptIds": [receipt.get("id") for receipt in receipts], "deliveryRunIds": [run.get("id") for run in delivery_runs], }, "tasks": selected, "workerReceipts": receipts, "deliveryRuns": delivery_runs, } return payload def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="选择 ACK 任务板的有预算 Coordinator 上下文" ) parser.add_argument( "tasks", nargs="?", default="docs/ack/tasks.yaml", help="任务板路径", ) parser.add_argument("--task-id", action="append", default=[]) parser.add_argument( "--status", action="append", default=[], choices=sorted(STATUS_ENUM), ) parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT) parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON") return parser def main(argv: list[str] | None = None) -> int: args = _parser().parse_args(argv) tasks_path = Path(args.tasks) if not tasks_path.is_file(): sys.stderr.write(f"找不到任务板文件: {tasks_path}\n") return 2 data = load_document(tasks_path) errors = validate_builtin(data) if errors: sys.stderr.write(f"任务板无效,拒绝选择,共 {len(errors)} 项:\n") for error in errors: sys.stderr.write(f" - {error}\n") return 1 try: mode, criteria, selected = select_tasks( data, task_ids=args.task_id, statuses=args.status, limit=args.limit, ) payload = build_payload( data, mode=mode, criteria=criteria, selected=selected, ) except SelectionError as exc: sys.stderr.write(f"任务选择失败: {exc}\n") return 1 if args.compact: output = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) else: output = json.dumps(payload, ensure_ascii=False, indent=2) if len(output.encode("utf-8")) > MAX_OUTPUT_BYTES: sys.stderr.write( f"任务选择输出超过 {MAX_OUTPUT_BYTES} bytes;请进一步缩小任务范围\n" ) return 1 sys.stdout.write(output + "\n") return 0 if __name__ == "__main__": raise SystemExit(main())