feat: update feishu intake
This commit is contained in:
@@ -26,6 +26,9 @@ from typing import Any
|
||||
from yaml_subset import DuplicateKeyError, YamlSubsetError, load_json_unique, load_yaml_subset, make_unique_pyyaml_loader
|
||||
|
||||
REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt")
|
||||
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
|
||||
COORDINATOR_FIELDS = ("steps", "acceptance", "priority")
|
||||
BUG_CONTENT_FIELDS = ("title", "actual", "expected", *COORDINATOR_FIELDS)
|
||||
MAX_PAGES = 100
|
||||
MAX_RECORDS = 10_000
|
||||
PAGE_SIZE = 100
|
||||
@@ -444,6 +447,7 @@ def source_ref(config: dict[str, Any], record_id: str) -> str:
|
||||
def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
||||
profile_check(config)
|
||||
prepared: list[tuple[dict[str, Any], list[tuple[dict[str, Any], str]]]] = []
|
||||
batch_warnings: list[dict[str, str]] = []
|
||||
total_attachments = 0
|
||||
total_attachment_bytes = 0
|
||||
for record_id, row in fetch_pages(config):
|
||||
@@ -456,9 +460,15 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
||||
if total_attachment_bytes > MAX_TOTAL_ATTACHMENT_BYTES:
|
||||
raise IntakeError("batch exceeded the attachment byte limit")
|
||||
record = {"sourceRef": source_ref(config, record_id), "recordId": record_id, "updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]), "actual": text(cells["actual"]), "expected": text(cells["expected"]), "steps": text(cells["stepsToReproduce"]), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
||||
for field in ("title", "actual", "expected", "steps", "acceptance", "priority", "updatedAt"):
|
||||
if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS):
|
||||
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
|
||||
continue
|
||||
for field in SOURCE_FACT_FIELDS:
|
||||
if not record[field]:
|
||||
raise IntakeError(f"record {field} must not be empty")
|
||||
record["enrichmentRequired"] = [
|
||||
field for field in COORDINATOR_FIELDS if not record[field]
|
||||
]
|
||||
prepared.append((record, attachment_data))
|
||||
download_root: Path | None = None
|
||||
if output_dir is not None:
|
||||
@@ -483,10 +493,10 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
||||
attachment["size"], min(60, remaining),
|
||||
)
|
||||
records.append(record)
|
||||
return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records}
|
||||
return {"provider": "feishu-base", "profile": config["profile"], "tableId": config["tableId"], "viewId": config["viewId"], "records": records, "warnings": batch_warnings}
|
||||
|
||||
|
||||
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Plan idempotent Coordinator actions without mutating the task board."""
|
||||
tasks = board.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
@@ -513,7 +523,7 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
|
||||
raise IntakeError("task board contains duplicate Feishu source references")
|
||||
existing[ref] = task
|
||||
|
||||
actions: list[dict[str, str]] = []
|
||||
actions: list[dict[str, Any]] = []
|
||||
seen_records: set[str] = set()
|
||||
for record in records:
|
||||
ref = record.get("sourceRef")
|
||||
@@ -530,22 +540,30 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
|
||||
seen_records.add(ref)
|
||||
task = existing.get(ref)
|
||||
if task is None:
|
||||
actions.append({"sourceRef": ref, "recordId": record_id, "action": "create"})
|
||||
planned_action: dict[str, Any] = {"sourceRef": ref, "recordId": record_id, "action": "create"}
|
||||
enrichment_required = record.get("enrichmentRequired")
|
||||
if enrichment_required:
|
||||
planned_action["enrichmentRequired"] = enrichment_required
|
||||
actions.append(planned_action)
|
||||
continue
|
||||
source = task["source"]
|
||||
if source["updatedAt"] == updated_at:
|
||||
action = "unchanged"
|
||||
action_name = "unchanged"
|
||||
elif task["status"] == "open":
|
||||
action = "refresh"
|
||||
action_name = "refresh"
|
||||
else:
|
||||
action = "drift"
|
||||
actions.append({
|
||||
action_name = "drift"
|
||||
planned_action = {
|
||||
"sourceRef": ref,
|
||||
"recordId": record_id,
|
||||
"taskId": task["id"],
|
||||
"status": task["status"],
|
||||
"action": action,
|
||||
})
|
||||
"action": action_name,
|
||||
}
|
||||
enrichment_required = record.get("enrichmentRequired")
|
||||
if enrichment_required and action_name == "refresh":
|
||||
planned_action["enrichmentRequired"] = enrichment_required
|
||||
actions.append(planned_action)
|
||||
return actions
|
||||
|
||||
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user