feat(ack): refine intake and validation workflow
This commit is contained in:
@@ -8,7 +8,7 @@ import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
PAYLOAD_FIELDS = (
|
||||
LEGACY_PAYLOAD_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"priority",
|
||||
@@ -18,6 +18,13 @@ PAYLOAD_FIELDS = (
|
||||
"fixLogic",
|
||||
"acceptanceCriteria",
|
||||
)
|
||||
CLARIFIED_PAYLOAD_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"actual",
|
||||
"expected",
|
||||
"acceptanceCriteria",
|
||||
)
|
||||
NUMBERED_ITEM = re.compile(r"(?:^|\s)([1-9][0-9]*)\.\s+")
|
||||
|
||||
|
||||
@@ -43,7 +50,14 @@ def review_items(value: str) -> list[str]:
|
||||
|
||||
def approval_payload_hash(task: dict[str, Any]) -> str:
|
||||
"""Hash the exact reviewed fields that Developer and Test consume."""
|
||||
payload = {field: task.get(field) for field in PAYLOAD_FIELDS}
|
||||
source = task.get("source")
|
||||
workflow = source.get("workflow") if isinstance(source, dict) else None
|
||||
fields = (
|
||||
CLARIFIED_PAYLOAD_FIELDS
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else LEGACY_PAYLOAD_FIELDS
|
||||
)
|
||||
payload = {field: task.get(field) for field in fields}
|
||||
encoded = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
@@ -25,10 +25,14 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from approval_payload import approval_payload_hash, review_items
|
||||
from validate_tasks import validate_builtin as validate_task_board
|
||||
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")
|
||||
OPTIONAL_FIELDS = ("fixLogic",)
|
||||
LEGACY_REQUIRED_FIELDS = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "attachments", "updatedAt")
|
||||
LEGACY_OPTIONAL_FIELDS = ("priority", "fixLogic")
|
||||
LEGACY_FIELD_ORDER = ("title", "actual", "expected", "stepsToReproduce", "acceptance", "priority", "attachments", "updatedAt", "fixLogic")
|
||||
CLARIFIED_REQUIRED_FIELDS = ("title", "details", "problemStatement", "expectedOutcome", "acceptance", "intakeStatus", "ackTaskId", "attachments", "updatedAt")
|
||||
CLARIFIED_FIELD_ORDER = CLARIFIED_REQUIRED_FIELDS
|
||||
SOURCE_FACT_FIELDS = ("title", "actual", "expected", "updatedAt")
|
||||
COORDINATOR_FIELDS = ("steps", "acceptance", "priority")
|
||||
BUG_CONTENT_FIELDS = ("title", "actual", "expected", "fixLogic", *COORDINATOR_FIELDS)
|
||||
@@ -48,7 +52,13 @@ PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
RECORD_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
SOURCE_REF = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
DRAFT_REVISION = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
|
||||
WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
INTAKE_STATUSES = ("待整理", "需补充", "待审核", "已确认", "已导入")
|
||||
TARGET_BASE_FIELDS = (
|
||||
("标题", "text"), ("详细描述", "text"), ("附件", "attachment"),
|
||||
("问题说明", "text"), ("期望效果", "text"), ("验收标准", "text"),
|
||||
("处理状态", "select"), ("ACK任务ID", "text"), ("更新时间", "updated_at"),
|
||||
)
|
||||
|
||||
|
||||
class IntakeError(Exception):
|
||||
@@ -188,7 +198,7 @@ def load_board(path: Path) -> dict[str, Any]:
|
||||
return value
|
||||
|
||||
|
||||
def load_draft(path: Path) -> dict[str, Any]:
|
||||
def load_draft(path: Path, workflow: str = "reviewed-writeback-v1") -> dict[str, Any]:
|
||||
"""Load one bounded, regular JSON file with the two writable draft fields."""
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
@@ -224,6 +234,23 @@ def load_draft(path: Path) -> dict[str, Any]:
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
if workflow == "clarified-writeback-v1":
|
||||
required = {"problemStatement", "expectedOutcome", "acceptance"}
|
||||
if not isinstance(value, dict) or set(value) != required:
|
||||
raise IntakeError("draft input must contain exactly problemStatement, expectedOutcome and acceptance")
|
||||
for field in ("problemStatement", "expectedOutcome"):
|
||||
if not isinstance(value[field], str) or not value[field].strip():
|
||||
raise IntakeError(f"draft {field} must be a non-empty string")
|
||||
acceptance = value["acceptance"]
|
||||
if not isinstance(acceptance, list) or not acceptance or any(
|
||||
not isinstance(item, str) or not item.strip() for item in acceptance
|
||||
):
|
||||
raise IntakeError("draft acceptance must be a non-empty string list")
|
||||
return {
|
||||
"problemStatement": value["problemStatement"].strip(),
|
||||
"expectedOutcome": value["expectedOutcome"].strip(),
|
||||
"acceptance": [item.strip() for item in acceptance],
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != {"fixLogic", "acceptance"}:
|
||||
raise IntakeError("draft input must contain exactly fixLogic and acceptance")
|
||||
fix_logic = value["fixLogic"]
|
||||
@@ -266,10 +293,15 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, str) or not SAFE_VALUE.fullmatch(value):
|
||||
raise IntakeError(f"bugIntake.{key} is invalid")
|
||||
fields = config.get("fields")
|
||||
supported = set(REQUIRED_FIELDS) | set(OPTIONAL_FIELDS)
|
||||
if workflow == "clarified-writeback-v1":
|
||||
required = CLARIFIED_REQUIRED_FIELDS
|
||||
supported = set(required)
|
||||
else:
|
||||
required = LEGACY_REQUIRED_FIELDS
|
||||
supported = set(required) | set(LEGACY_OPTIONAL_FIELDS)
|
||||
if (
|
||||
not isinstance(fields, dict)
|
||||
or not set(REQUIRED_FIELDS).issubset(fields)
|
||||
or not set(required).issubset(fields)
|
||||
or not set(fields).issubset(supported)
|
||||
):
|
||||
raise IntakeError("bugIntake.fields must map all required and only supported logical fields")
|
||||
@@ -277,8 +309,12 @@ def config_from_board(board: dict[str, Any]) -> dict[str, Any]:
|
||||
raise IntakeError("bugIntake.fields values are invalid")
|
||||
if len(set(fields.values())) != len(fields):
|
||||
raise IntakeError("bugIntake.fields values must be unique")
|
||||
if workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
|
||||
raise IntakeError("reviewed writeback requires bugIntake.fields.fixLogic")
|
||||
if workflow == "reviewed-writeback-v1":
|
||||
missing_review_fields = {"fixLogic", "priority"} - set(fields)
|
||||
if missing_review_fields:
|
||||
raise IntakeError(
|
||||
"reviewed writeback requires bugIntake.fields.fixLogic and priority"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -441,19 +477,34 @@ def matrix_from_response(response: dict[str, Any], field_ids: list[str]) -> tupl
|
||||
rows = data.get("data", data.get("records", data.get("items", data.get("rows"))))
|
||||
if not isinstance(fields, list) or not all(isinstance(x, str) for x in fields):
|
||||
raise IntakeError("record list fields are invalid")
|
||||
if fields != field_ids:
|
||||
raise IntakeError("record list fields do not match configured projection")
|
||||
if len(set(fields)) != len(fields) or len(set(field_ids)) != len(field_ids):
|
||||
raise IntakeError("record list field projection contains duplicates")
|
||||
if set(fields) != set(field_ids):
|
||||
raise IntakeError(
|
||||
"record list fields do not match configured projection: "
|
||||
f"expected={field_ids!r}, actual={fields!r}"
|
||||
)
|
||||
if not isinstance(ids, list) or not all(isinstance(x, str) and RECORD_ID.fullmatch(x) for x in ids):
|
||||
raise IntakeError("record list record_id_list is invalid")
|
||||
if not isinstance(rows, list) or len(rows) != len(ids) or any(not isinstance(row, list) or len(row) != len(fields) for row in rows):
|
||||
raise IntakeError("record list matrix does not match fields and record_id_list")
|
||||
return ids, rows
|
||||
if fields == field_ids:
|
||||
return ids, rows
|
||||
positions = {field: index for index, field in enumerate(fields)}
|
||||
return ids, [
|
||||
[row[positions[field_id]] for field_id in field_ids]
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def fetch_pages(config: dict[str, Any]) -> list[tuple[str, list[Any]]]:
|
||||
field_order = (
|
||||
CLARIFIED_FIELD_ORDER
|
||||
if config.get("workflow") == "clarified-writeback-v1"
|
||||
else LEGACY_FIELD_ORDER
|
||||
)
|
||||
logical_fields = [
|
||||
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
|
||||
if logical in config["fields"]
|
||||
logical for logical in field_order if logical in config["fields"]
|
||||
]
|
||||
field_ids = [config["fields"][logical] for logical in logical_fields]
|
||||
all_rows: list[tuple[str, list[Any]]] = []
|
||||
@@ -526,23 +577,20 @@ def draft_revision(
|
||||
if len(tokens) != len(record["attachments"]):
|
||||
raise IntakeError("draft revision attachment identity is incomplete")
|
||||
stable = {
|
||||
"sourceRef": record["sourceRef"],
|
||||
"updatedAt": record["updatedAt"],
|
||||
"title": record["title"],
|
||||
"actual": record["actual"],
|
||||
"expected": record["expected"],
|
||||
"steps": record["steps"],
|
||||
"fixLogic": record["fixLogic"],
|
||||
"acceptance": record["acceptance"],
|
||||
"priority": record["priority"],
|
||||
"attachments": [
|
||||
{
|
||||
**{key: attachment.get(key) for key in ("name", "type", "size")},
|
||||
"tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}",
|
||||
}
|
||||
for attachment, token in zip(record["attachments"], tokens)
|
||||
],
|
||||
key: record[key]
|
||||
for key in record
|
||||
if key not in {
|
||||
"attachments", "warnings", "enrichmentRequired", "draftRevision", "recordId",
|
||||
"intakeStatus", "ackTaskId",
|
||||
}
|
||||
}
|
||||
stable["attachments"] = [
|
||||
{
|
||||
**{key: attachment.get(key) for key in ("name", "type", "size")},
|
||||
"tokenDigest": f"sha256:{hashlib.sha256(('ack-feishu-attachment-v1\x1f' + token).encode('utf-8')).hexdigest()}",
|
||||
}
|
||||
for attachment, token in zip(record["attachments"], tokens)
|
||||
]
|
||||
encoded = json.dumps(
|
||||
stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
@@ -556,10 +604,9 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
||||
total_attachments = 0
|
||||
total_attachment_bytes = 0
|
||||
for record_id, row in fetch_pages(config):
|
||||
logical_fields = [
|
||||
logical for logical in (*REQUIRED_FIELDS, *OPTIONAL_FIELDS)
|
||||
if logical in config["fields"]
|
||||
]
|
||||
workflow = config.get("workflow", "read-only-v1")
|
||||
field_order = CLARIFIED_FIELD_ORDER if workflow == "clarified-writeback-v1" else LEGACY_FIELD_ORDER
|
||||
logical_fields = [logical for logical in field_order if logical in config["fields"]]
|
||||
cells = dict(zip(logical_fields, row))
|
||||
attachment_data = attachment_items(cells["attachments"])
|
||||
total_attachments += len(attachment_data)
|
||||
@@ -568,15 +615,35 @@ def fetch(config: dict[str, Any], output_dir: Path | None) -> dict[str, Any]:
|
||||
raise IntakeError("batch exceeded the attachment count limit")
|
||||
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"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells["priority"]), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
||||
if not attachment_data and not any(record[field] for field in BUG_CONTENT_FIELDS):
|
||||
if workflow == "clarified-writeback-v1":
|
||||
record = {
|
||||
"sourceRef": source_ref(config, record_id), "recordId": record_id,
|
||||
"updatedAt": text(cells["updatedAt"]), "title": text(cells["title"]),
|
||||
"details": text(cells["details"]),
|
||||
"problemStatement": text(cells["problemStatement"]),
|
||||
"expectedOutcome": text(cells["expectedOutcome"]),
|
||||
"acceptance": text(cells["acceptance"]),
|
||||
"intakeStatus": text(cells["intakeStatus"]),
|
||||
"ackTaskId": text(cells["ackTaskId"]),
|
||||
"attachments": [metadata for metadata, _ in attachment_data], "warnings": [],
|
||||
}
|
||||
else:
|
||||
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"]), "fixLogic": text(cells.get("fixLogic")), "acceptance": text(cells["acceptance"]), "priority": text(cells.get("priority")), "attachments": [metadata for metadata, _ in attachment_data], "warnings": []}
|
||||
content_fields = (
|
||||
("title", "details", "problemStatement", "expectedOutcome", "acceptance")
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else BUG_CONTENT_FIELDS
|
||||
)
|
||||
if not attachment_data and not any(record.get(field) for field in content_fields):
|
||||
batch_warnings.append({"recordId": record_id, "code": "blank_record_skipped"})
|
||||
continue
|
||||
for field in SOURCE_FACT_FIELDS:
|
||||
source_fields = ("title", "updatedAt") if workflow == "clarified-writeback-v1" else SOURCE_FACT_FIELDS
|
||||
for field in source_fields:
|
||||
if not record[field]:
|
||||
raise IntakeError(f"record {field} must not be empty")
|
||||
enrichment_fields = list(COORDINATOR_FIELDS)
|
||||
if "fixLogic" in config["fields"]:
|
||||
enrichment_fields = (["problemStatement", "expectedOutcome", "acceptance"]
|
||||
if workflow == "clarified-writeback-v1" else list(COORDINATOR_FIELDS))
|
||||
if workflow != "clarified-writeback-v1" and "fixLogic" in config["fields"]:
|
||||
enrichment_fields.append("fixLogic")
|
||||
record["enrichmentRequired"] = [
|
||||
field for field in enrichment_fields if not record[field]
|
||||
@@ -640,19 +707,32 @@ def write_draft(
|
||||
config: dict[str, Any], record_id: str, expected_source_ref: str,
|
||||
expected_revision: str, draft_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Overwrite only the configured fix logic and acceptance cells."""
|
||||
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
|
||||
raise IntakeError("draft writeback requires reviewed-writeback-v1 workflow")
|
||||
if "fixLogic" not in config["fields"]:
|
||||
"""Overwrite only the review-owned cells for the configured workflow."""
|
||||
workflow = config.get("workflow", "read-only-v1")
|
||||
if workflow not in {"reviewed-writeback-v1", "clarified-writeback-v1"}:
|
||||
raise IntakeError("draft writeback requires a writeback workflow")
|
||||
if workflow == "reviewed-writeback-v1" and "fixLogic" not in config["fields"]:
|
||||
raise IntakeError("bugIntake.fields.fixLogic is required for draft writeback")
|
||||
review_record(config, record_id, expected_source_ref, expected_revision)
|
||||
draft = load_draft(draft_path)
|
||||
patch = {
|
||||
config["fields"]["fixLogic"]: draft["fixLogic"],
|
||||
config["fields"]["acceptance"]: "\n".join(
|
||||
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
|
||||
),
|
||||
}
|
||||
draft = load_draft(draft_path, workflow)
|
||||
if workflow == "clarified-writeback-v1":
|
||||
patch = {
|
||||
config["fields"]["problemStatement"]: draft["problemStatement"],
|
||||
config["fields"]["expectedOutcome"]: draft["expectedOutcome"],
|
||||
config["fields"]["acceptance"]: "\n".join(
|
||||
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
|
||||
),
|
||||
config["fields"]["intakeStatus"]: "待审核",
|
||||
}
|
||||
written = ["problemStatement", "expectedOutcome", "acceptance", "intakeStatus"]
|
||||
else:
|
||||
patch = {
|
||||
config["fields"]["fixLogic"]: draft["fixLogic"],
|
||||
config["fields"]["acceptance"]: "\n".join(
|
||||
f"{index}. {item}" for index, item in enumerate(draft["acceptance"], start=1)
|
||||
),
|
||||
}
|
||||
written = ["fixLogic", "acceptance"]
|
||||
profile_check(config)
|
||||
run_cli([
|
||||
"base", "+record-upsert", "--profile", config["profile"],
|
||||
@@ -665,15 +745,21 @@ def write_draft(
|
||||
if len(matching) != 1 or matching[0]["sourceRef"] != expected_source_ref:
|
||||
raise IntakeError("draft writeback readback did not find exactly one record")
|
||||
expected_acceptance = text(patch[config["fields"]["acceptance"]])
|
||||
if (
|
||||
matching[0]["fixLogic"] != text(draft["fixLogic"])
|
||||
or matching[0]["acceptance"] != expected_acceptance
|
||||
):
|
||||
if workflow == "clarified-writeback-v1":
|
||||
matched = (
|
||||
matching[0]["problemStatement"] == text(draft["problemStatement"])
|
||||
and matching[0]["expectedOutcome"] == text(draft["expectedOutcome"])
|
||||
and matching[0]["acceptance"] == expected_acceptance
|
||||
and matching[0]["intakeStatus"] == "待审核"
|
||||
)
|
||||
else:
|
||||
matched = matching[0]["fixLogic"] == text(draft["fixLogic"]) and matching[0]["acceptance"] == expected_acceptance
|
||||
if not matched:
|
||||
raise IntakeError("draft writeback readback did not match the submitted draft")
|
||||
return {
|
||||
"provider": "feishu-base",
|
||||
"recordId": record_id,
|
||||
"written": ["fixLogic", "acceptance"],
|
||||
"written": written,
|
||||
"draftRevision": matching[0]["draftRevision"],
|
||||
"ok": True,
|
||||
}
|
||||
@@ -684,13 +770,21 @@ def import_approved(
|
||||
expected_revision: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Emit the canonical task payload for one explicitly approved draft revision."""
|
||||
if config.get("workflow", "read-only-v1") != "reviewed-writeback-v1":
|
||||
raise IntakeError("approved import requires reviewed-writeback-v1 workflow")
|
||||
workflow = config.get("workflow", "read-only-v1")
|
||||
if workflow not in {"reviewed-writeback-v1", "clarified-writeback-v1"}:
|
||||
raise IntakeError("approved import requires a writeback workflow")
|
||||
record = review_record(
|
||||
config, record_id, expected_source_ref, expected_revision,
|
||||
)
|
||||
steps = review_items(record["steps"])
|
||||
acceptance = review_items(record["acceptance"])
|
||||
if workflow == "clarified-writeback-v1":
|
||||
if record["intakeStatus"] != "已确认":
|
||||
raise IntakeError("approved record must have intakeStatus 已确认")
|
||||
if not record["problemStatement"] or not record["expectedOutcome"] or not acceptance:
|
||||
raise IntakeError("approved record is missing prepared clarification fields")
|
||||
task_draft = clarified_task_draft(record)
|
||||
return {"provider": "feishu-base", "recordId": record_id, "draftRevision": record["draftRevision"], "taskDraft": task_draft, "ok": True}
|
||||
steps = review_items(record["steps"])
|
||||
if not record["priority"] or not steps or not record["fixLogic"] or not acceptance:
|
||||
raise IntakeError("approved record is missing prepared review fields")
|
||||
task_draft: dict[str, Any] = {
|
||||
@@ -721,6 +815,101 @@ def import_approved(
|
||||
}
|
||||
|
||||
|
||||
def clarified_task_draft(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map one normalized clarified record to its immutable reviewed task fields."""
|
||||
task_draft: dict[str, Any] = {
|
||||
"title": record["title"],
|
||||
"description": record["problemStatement"],
|
||||
"actual": record["details"] or record["title"],
|
||||
"expected": record["expectedOutcome"],
|
||||
"acceptanceCriteria": review_items(record["acceptance"]),
|
||||
"source": {
|
||||
"kind": "feishu-base",
|
||||
"workflow": "clarified-writeback-v1",
|
||||
"ref": record["sourceRef"],
|
||||
"recordId": record["recordId"],
|
||||
"updatedAt": record["updatedAt"],
|
||||
"approvedRevision": record["draftRevision"],
|
||||
},
|
||||
}
|
||||
task_draft["source"]["approvedPayloadHash"] = approval_payload_hash(task_draft)
|
||||
return task_draft
|
||||
|
||||
|
||||
def mark_imported(
|
||||
board: dict[str, Any], config: dict[str, Any], record_id: str,
|
||||
expected_source_ref: str, expected_revision: str, task_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Bind a confirmed Base record to the validated ACK task created from it."""
|
||||
if config.get("workflow") != "clarified-writeback-v1":
|
||||
raise IntakeError("mark-imported requires clarified-writeback-v1 workflow")
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise IntakeError("ACK task id is invalid")
|
||||
if validate_task_board(board):
|
||||
raise IntakeError("task board is invalid for import finalization")
|
||||
record = review_record(config, record_id, expected_source_ref, expected_revision)
|
||||
|
||||
tasks = board.get("tasks")
|
||||
if not isinstance(tasks, list):
|
||||
raise IntakeError("task board tasks must be a list")
|
||||
matches = [
|
||||
task for task in tasks
|
||||
if isinstance(task, dict) and task.get("id") == task_id
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise IntakeError("task board did not contain exactly one imported ACK task")
|
||||
task = matches[0]
|
||||
source = task.get("source")
|
||||
expected_task = clarified_task_draft(record)
|
||||
reviewed_fields = (
|
||||
"title", "description", "actual", "expected", "acceptanceCriteria",
|
||||
)
|
||||
if (
|
||||
not isinstance(source, dict)
|
||||
or source != expected_task["source"]
|
||||
or any(task.get(field) != expected_task[field] for field in reviewed_fields)
|
||||
):
|
||||
raise IntakeError("ACK task does not match the approved Base record")
|
||||
|
||||
if record["intakeStatus"] == "已导入" and record["ackTaskId"] == task_id:
|
||||
return {
|
||||
"provider": "feishu-base", "recordId": record_id,
|
||||
"intakeStatus": "已导入", "ackTaskId": task_id,
|
||||
"draftRevision": expected_revision, "ok": True,
|
||||
}
|
||||
if record["intakeStatus"] != "已确认" or record["ackTaskId"]:
|
||||
raise IntakeError("Base record is not ready to mark as imported")
|
||||
|
||||
profile_check(config)
|
||||
run_cli([
|
||||
"base", "+record-upsert", "--profile", config["profile"],
|
||||
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||
"--record-id", record_id, "--json",
|
||||
json.dumps({
|
||||
config["fields"]["intakeStatus"]: "已导入",
|
||||
config["fields"]["ackTaskId"]: task_id,
|
||||
}, ensure_ascii=False, separators=(",", ":")),
|
||||
"--format", "json",
|
||||
])
|
||||
matching = [
|
||||
item for item in fetch(config, None)["records"]
|
||||
if item["recordId"] == record_id
|
||||
]
|
||||
if (
|
||||
len(matching) != 1
|
||||
or matching[0]["sourceRef"] != expected_source_ref
|
||||
or matching[0]["draftRevision"] != expected_revision
|
||||
or matching[0]["intakeStatus"] != "已导入"
|
||||
or matching[0]["ackTaskId"] != task_id
|
||||
):
|
||||
raise IntakeError("import marker readback did not match the ACK task")
|
||||
return {
|
||||
"provider": "feishu-base", "recordId": record_id,
|
||||
"intakeStatus": "已导入", "ackTaskId": task_id,
|
||||
"draftRevision": expected_revision, "ok": True,
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
@@ -803,20 +992,190 @@ def plan_actions(board: dict[str, Any], records: list[dict[str, Any]]) -> list[d
|
||||
return actions
|
||||
|
||||
|
||||
def field_list(config: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""Return the bounded Base field inventory used by schema migration."""
|
||||
profile_check(config)
|
||||
response = run_cli([
|
||||
"base", "+field-list", "--profile", config["profile"],
|
||||
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||
"--format", "json",
|
||||
])
|
||||
data = response.get("data", response)
|
||||
items = data.get("fields") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list) or len(items) > 256:
|
||||
raise IntakeError("field list returned an invalid response")
|
||||
result: list[dict[str, str]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict) or not all(
|
||||
isinstance(item.get(key), str) and item[key]
|
||||
for key in ("id", "name", "type")
|
||||
):
|
||||
raise IntakeError("field list returned an invalid field")
|
||||
result.append({key: item[key] for key in ("id", "name", "type")})
|
||||
if len({item["name"] for item in result}) != len(result):
|
||||
raise IntakeError("field list contains duplicate names")
|
||||
return result
|
||||
|
||||
|
||||
def schema_target(config: dict[str, Any]) -> dict[str, str]:
|
||||
token_digest = hashlib.sha256(
|
||||
("ack-feishu-schema-target-v1\x1f" + config["baseToken"]).encode("utf-8")
|
||||
).hexdigest()
|
||||
return {
|
||||
"profile": config["profile"],
|
||||
"baseTokenDigest": f"sha256:{token_digest}",
|
||||
"tableId": config["tableId"],
|
||||
"viewId": config["viewId"],
|
||||
}
|
||||
|
||||
|
||||
def schema_fingerprint(config: dict[str, Any], fields: list[dict[str, str]]) -> str:
|
||||
encoded = json.dumps(
|
||||
{
|
||||
"contract": "clarified-writeback-v1",
|
||||
"target": schema_target(config),
|
||||
"fields": sorted(fields, key=lambda item: item["id"]),
|
||||
},
|
||||
ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
|
||||
|
||||
|
||||
def schema_plan(config: dict[str, Any]) -> dict[str, Any]:
|
||||
if config.get("workflow") != "clarified-writeback-v1":
|
||||
raise IntakeError("schema migration requires clarified-writeback-v1 workflow")
|
||||
fields = field_list(config)
|
||||
by_name = {item["name"]: item for item in fields}
|
||||
missing = [name for name, _ in TARGET_BASE_FIELDS if name not in by_name]
|
||||
wrong_type = [
|
||||
{"name": name, "expected": field_type, "actual": by_name[name]["type"]}
|
||||
for name, field_type in TARGET_BASE_FIELDS
|
||||
if name in by_name and by_name[name]["type"] != field_type
|
||||
]
|
||||
return {
|
||||
"provider": "feishu-base",
|
||||
"target": schema_target(config),
|
||||
"schemaFingerprint": schema_fingerprint(config, fields),
|
||||
"missingFields": missing,
|
||||
"typeConflicts": wrong_type,
|
||||
"visibleFields": [name for name, _ in TARGET_BASE_FIELDS],
|
||||
"legacyFieldsPreserved": [
|
||||
name for name in ("期望结果", "问题澄清", "复现步骤", "ACK Ready")
|
||||
if name in by_name
|
||||
],
|
||||
"ok": not wrong_type,
|
||||
}
|
||||
|
||||
|
||||
def create_target_field(config: dict[str, Any], name: str) -> None:
|
||||
field_type = dict(TARGET_BASE_FIELDS)[name]
|
||||
if field_type in {"attachment", "updated_at"}:
|
||||
raise IntakeError("schema migration cannot create a missing system/source field")
|
||||
payload: dict[str, Any] = {"name": name, "type": field_type}
|
||||
if name == "处理状态":
|
||||
payload.update({"multiple": False, "options": [{"name": value} for value in INTAKE_STATUSES]})
|
||||
run_cli([
|
||||
"base", "+field-create", "--profile", config["profile"],
|
||||
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||
"--json", json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||
"--format", "json",
|
||||
])
|
||||
|
||||
|
||||
def migration_rows(config: dict[str, Any], fields: list[dict[str, str]]) -> list[tuple[str, dict[str, Any]]]:
|
||||
by_name = {item["name"]: item for item in fields}
|
||||
names = ["标题", "详细描述", "期望结果", "处理状态"]
|
||||
present = [name for name in names if name in by_name]
|
||||
# record-list projects cells by configured field name and returns those
|
||||
# names in its matrix, even when the REST field inventory exposes IDs.
|
||||
ids = present
|
||||
args = [
|
||||
"base", "+record-list", "--profile", config["profile"],
|
||||
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||
"--view-id", config["viewId"], "--format", "json", "--offset", "0",
|
||||
"--limit", str(PAGE_SIZE),
|
||||
]
|
||||
for field_id in ids:
|
||||
args.extend(["--field-id", field_id])
|
||||
response = run_cli(args)
|
||||
record_ids, rows = matrix_from_response(response, ids)
|
||||
data = response.get("data", response)
|
||||
if data.get("has_more", data.get("hasMore", False)):
|
||||
raise IntakeError("schema migration view exceeded one bounded page")
|
||||
return [(record_id, dict(zip(present, row))) for record_id, row in zip(record_ids, rows)]
|
||||
|
||||
|
||||
def schema_apply(config: dict[str, Any], expected_fingerprint: str) -> dict[str, Any]:
|
||||
if DRAFT_REVISION.fullmatch(expected_fingerprint) is None:
|
||||
raise IntakeError("expected schema fingerprint is invalid")
|
||||
before = schema_plan(config)
|
||||
if before["schemaFingerprint"] != expected_fingerprint:
|
||||
raise IntakeError("Base schema changed after planning")
|
||||
if before["typeConflicts"]:
|
||||
raise IntakeError("Base schema has incompatible target field types")
|
||||
for name in before["missingFields"]:
|
||||
create_target_field(config, name)
|
||||
fields = field_list(config)
|
||||
for _ in range(4):
|
||||
if all(name in {item["name"] for item in fields} for name, _ in TARGET_BASE_FIELDS):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
fields = field_list(config)
|
||||
by_name = {item["name"]: item for item in fields}
|
||||
if any(name not in by_name for name, _ in TARGET_BASE_FIELDS):
|
||||
raise IntakeError("schema migration did not create all target fields")
|
||||
migrated: list[str] = []
|
||||
for record_id, cells in migration_rows(config, fields):
|
||||
details = text(cells.get("详细描述"))
|
||||
legacy_expected = text(cells.get("期望结果"))
|
||||
patch: dict[str, Any] = {}
|
||||
marker = f"用户原始期望:{legacy_expected}" if legacy_expected else ""
|
||||
if marker and marker not in details:
|
||||
patch["详细描述"] = f"{details}\n\n{marker}".strip()
|
||||
if not text(cells.get("处理状态")):
|
||||
patch["处理状态"] = "待整理"
|
||||
if patch:
|
||||
run_cli([
|
||||
"base", "+record-upsert", "--profile", config["profile"],
|
||||
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||
"--record-id", record_id, "--json",
|
||||
json.dumps(patch, ensure_ascii=False, separators=(",", ":")),
|
||||
"--format", "json",
|
||||
])
|
||||
migrated.append(record_id)
|
||||
visible_ids = [by_name[name]["id"] for name, _ in TARGET_BASE_FIELDS]
|
||||
run_cli([
|
||||
"base", "+view-set-visible-fields", "--profile", config["profile"],
|
||||
"--base-token", config["baseToken"], "--table-id", config["tableId"],
|
||||
"--view-id", config["viewId"], "--json",
|
||||
json.dumps({"visible_fields": visible_ids}, separators=(",", ":")),
|
||||
"--format", "json",
|
||||
])
|
||||
return {
|
||||
"provider": "feishu-base", "createdFields": before["missingFields"],
|
||||
"migratedRecordIds": migrated, "visibleFields": [name for name, _ in TARGET_BASE_FIELDS],
|
||||
"schemaFingerprint": schema_fingerprint(config, fields), "ok": True,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Read and review a configured Feishu Base bug intake")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("check", "fetch", "plan", "write-draft", "import-approved"):
|
||||
for name in ("check", "fetch", "plan", "write-draft", "import-approved", "mark-imported", "schema-plan", "schema-apply"):
|
||||
command = sub.add_parser(name)
|
||||
command.add_argument("tasks", type=Path, help="ACK tasks.yaml or JSON board")
|
||||
if name in {"fetch", "plan"}:
|
||||
command.add_argument("--output-dir", type=Path, help="explicit directory for downloaded attachments")
|
||||
if name in {"write-draft", "import-approved"}:
|
||||
if name in {"write-draft", "import-approved", "mark-imported"}:
|
||||
command.add_argument("--record-id", required=True, help="existing Feishu Base record id")
|
||||
command.add_argument("--expected-source-ref", required=True, help="sourceRef returned by fetch")
|
||||
command.add_argument("--expected-draft-revision", required=True, help="draftRevision returned by fetch")
|
||||
if name == "write-draft":
|
||||
command.add_argument("--input", type=Path, required=True, help="bounded JSON draft file")
|
||||
if name == "mark-imported":
|
||||
command.add_argument("--task-id", required=True, help="validated ACK task id")
|
||||
if name == "schema-apply":
|
||||
command.add_argument("--expected-schema-fingerprint", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
board = load_board(args.tasks)
|
||||
@@ -829,11 +1188,20 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif args.command == "plan":
|
||||
output = fetch(config, args.output_dir)
|
||||
output["actions"] = plan_actions(board, output["records"])
|
||||
elif args.command == "schema-plan":
|
||||
output = schema_plan(config)
|
||||
elif args.command == "schema-apply":
|
||||
output = schema_apply(config, args.expected_schema_fingerprint)
|
||||
elif args.command == "write-draft":
|
||||
output = write_draft(
|
||||
config, args.record_id, args.expected_source_ref,
|
||||
args.expected_draft_revision, args.input,
|
||||
)
|
||||
elif args.command == "mark-imported":
|
||||
output = mark_imported(
|
||||
board, config, args.record_id, args.expected_source_ref,
|
||||
args.expected_draft_revision, args.task_id,
|
||||
)
|
||||
else:
|
||||
output = import_approved(
|
||||
config, args.record_id, args.expected_source_ref,
|
||||
|
||||
@@ -1071,6 +1071,8 @@ def build_receipt(
|
||||
"profileId": plan["profileId"],
|
||||
"profileHash": plan["profileHash"],
|
||||
"launchFingerprint": plan["launchFingerprint"],
|
||||
"projectRoot": plan["projectRoot"],
|
||||
"boardHash": plan["boardHash"],
|
||||
"slot": plan["slot"],
|
||||
"createdFor": {
|
||||
"taskId": plan["taskId"],
|
||||
|
||||
@@ -98,7 +98,7 @@ DESTINATION_TYPES = {"apt-repository", "oci-registry", "ci-artifact"}
|
||||
CHANNELS = {"preview", "staging", "stable"}
|
||||
ENVIRONMENT_TYPES = {"ssh-host", "docker-compose", "kubernetes", "custom"}
|
||||
CLASSIFICATIONS = {"development", "staging", "production"}
|
||||
STOP_POINTS = {"verified", "review_ready", "released"}
|
||||
STOP_POINTS = {"verified", "validation_ready", "review_ready", "released"}
|
||||
ACTIONS = {
|
||||
"verify",
|
||||
"pull-request",
|
||||
@@ -506,6 +506,7 @@ def _validate_profiles(
|
||||
built_artifacts: set[str] = set()
|
||||
published_artifacts: set[str] = set()
|
||||
deployed_environments: set[str] = set()
|
||||
checked_environments: set[str] = set()
|
||||
approvals: set[str] = set()
|
||||
has_pull_request = False
|
||||
has_mark_ready = False
|
||||
@@ -610,6 +611,8 @@ def _validate_profiles(
|
||||
errors.append(
|
||||
f"{step_where}: health-check 前必须先 deploy {environment_id!r}"
|
||||
)
|
||||
else:
|
||||
checked_environments.add(environment_id)
|
||||
if action == "approval":
|
||||
gate = step.get("gate")
|
||||
if gate not in {"release", "production"}:
|
||||
@@ -635,10 +638,21 @@ def _validate_profiles(
|
||||
)
|
||||
if stop_at == "released" and not ({"release", "production"} & approvals):
|
||||
errors.append(f"{where}: released profile 必须包含 release 或 production approval")
|
||||
if stop_at == "validation_ready":
|
||||
if not deployed_environments:
|
||||
errors.append(f"{where}: validation_ready 必须至少部署一个环境")
|
||||
missing_health = deployed_environments - checked_environments
|
||||
if missing_health:
|
||||
errors.append(
|
||||
f"{where}: validation_ready 的部署环境必须全部完成 health-check: "
|
||||
f"{sorted(missing_health)}"
|
||||
)
|
||||
|
||||
if profile_id == default_profile:
|
||||
if stop_at != "review_ready":
|
||||
errors.append(f"{where}: defaultProfile 必须停在 review_ready")
|
||||
if stop_at not in {"validation_ready", "review_ready"}:
|
||||
errors.append(
|
||||
f"{where}: defaultProfile 必须停在 validation_ready 或 review_ready"
|
||||
)
|
||||
used_destinations = {
|
||||
step.get("destination")
|
||||
for step in steps
|
||||
|
||||
@@ -108,6 +108,7 @@ DELIVERY_STATUSES = {
|
||||
"running",
|
||||
"blocked",
|
||||
"failed",
|
||||
"validation_ready",
|
||||
"review_ready",
|
||||
"released",
|
||||
"skipped",
|
||||
@@ -115,16 +116,20 @@ DELIVERY_STATUSES = {
|
||||
DELIVERY_ARTIFACT_FIELDS = {"id", "type", "reference", "digest"}
|
||||
DELIVERY_DEPLOYMENT_FIELDS = {"environment", "result", "evidence"}
|
||||
FEISHU_REQUIRED_FIELDS = {
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance", "priority",
|
||||
"title", "actual", "expected", "stepsToReproduce", "acceptance",
|
||||
"attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_OPTIONAL_FIELDS = {"fixLogic"}
|
||||
FEISHU_OPTIONAL_FIELDS = {"priority", "fixLogic"}
|
||||
FEISHU_CLARIFIED_FIELDS = {
|
||||
"title", "details", "problemStatement", "expectedOutcome", "acceptance",
|
||||
"intakeStatus", "ackTaskId", "attachments", "updatedAt",
|
||||
}
|
||||
FEISHU_CONFIG_FIELDS = {"provider", "workflow", "profile", "baseToken", "tableId", "viewId", "fields"}
|
||||
FEISHU_SOURCE_FIELDS = {
|
||||
"kind", "workflow", "ref", "recordId", "updatedAt", "approvedRevision",
|
||||
"approvedPayloadHash",
|
||||
}
|
||||
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1"}
|
||||
FEISHU_WORKFLOWS = {"read-only-v1", "reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
FEISHU_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
FEISHU_SOURCE_REF_RE = re.compile(r"^feishu-base:sha256:[0-9a-f]{64}$")
|
||||
FEISHU_RECORD_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$")
|
||||
@@ -135,7 +140,34 @@ DISPATCH_FIELDS = {
|
||||
"developer",
|
||||
"test",
|
||||
"rounds",
|
||||
"environmentIncidents",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_FIELDS = {
|
||||
"id",
|
||||
"attemptId",
|
||||
"role",
|
||||
"phase",
|
||||
"status",
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt",
|
||||
"resolvedAt",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_ROLES = {"coordinator", "developer", "test"}
|
||||
ENVIRONMENT_INCIDENT_PHASES = {
|
||||
"launch",
|
||||
"orchestration",
|
||||
"service",
|
||||
"test_data",
|
||||
"browser",
|
||||
"tooling",
|
||||
"permissions",
|
||||
"other",
|
||||
}
|
||||
ENVIRONMENT_INCIDENT_STATUSES = {"open", "resolved"}
|
||||
KNOWLEDGE_CANDIDATE_REQUIRED_FIELDS = {
|
||||
"kind",
|
||||
"title",
|
||||
@@ -541,7 +573,7 @@ def validate_delivery_runs(
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None
|
||||
):
|
||||
errors.append(f"{artifact_where}.digest: 必须是 sha256:<64 hex> 或 null")
|
||||
if status in {"review_ready", "released"} and not _nonempty_string(digest):
|
||||
if status in {"validation_ready", "review_ready", "released"} and not _nonempty_string(digest):
|
||||
errors.append(
|
||||
f"{artifact_where}.digest: status={status!r} 时必须填写"
|
||||
)
|
||||
@@ -584,7 +616,7 @@ def validate_delivery_runs(
|
||||
not _nonempty_string(item) for item in evidence
|
||||
):
|
||||
errors.append(f"{where}.evidence: 必须是字符串列表")
|
||||
elif status in {"blocked", "failed", "review_ready", "released", "skipped"} and not evidence:
|
||||
elif status in {"blocked", "failed", "validation_ready", "review_ready", "released", "skipped"} and not evidence:
|
||||
errors.append(f"{where}.evidence: status={status!r} 时不能为空")
|
||||
if not _nonempty_string(run.get("updatedAt")):
|
||||
errors.append(f"{where}.updatedAt: 必须是非空字符串")
|
||||
@@ -691,18 +723,30 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
if not isinstance(value, str) or not value.strip() or any(char.isspace() for char in value):
|
||||
errors.append(f"project.bugIntake.{key} 必须是无空白非空字符串")
|
||||
fields = intake.get("fields")
|
||||
expected_fields = (
|
||||
FEISHU_CLARIFIED_FIELDS
|
||||
if workflow == "clarified-writeback-v1"
|
||||
else FEISHU_REQUIRED_FIELDS
|
||||
)
|
||||
allowed_fields = expected_fields | (
|
||||
set() if workflow == "clarified-writeback-v1" else FEISHU_OPTIONAL_FIELDS
|
||||
)
|
||||
if (
|
||||
not isinstance(fields, dict)
|
||||
or not FEISHU_REQUIRED_FIELDS.issubset(fields)
|
||||
or not set(fields).issubset(FEISHU_REQUIRED_FIELDS | FEISHU_OPTIONAL_FIELDS)
|
||||
or not expected_fields.issubset(fields)
|
||||
or not set(fields).issubset(allowed_fields)
|
||||
):
|
||||
errors.append("project.bugIntake.fields 必须且只能映射所需逻辑字段")
|
||||
elif any(not isinstance(v, str) or not v.strip() or any(c.isspace() for c in v) for v in fields.values()):
|
||||
errors.append("project.bugIntake.fields 字段值必须是无空白非空字符串")
|
||||
elif len(set(fields.values())) != len(fields):
|
||||
errors.append("project.bugIntake.fields 字段值不能重复")
|
||||
elif workflow == "reviewed-writeback-v1" and "fixLogic" not in fields:
|
||||
errors.append("reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic")
|
||||
elif workflow == "reviewed-writeback-v1" and not {
|
||||
"fixLogic", "priority"
|
||||
}.issubset(fields):
|
||||
errors.append(
|
||||
"reviewed-writeback-v1 必须映射 project.bugIntake.fields.fixLogic 和 priority"
|
||||
)
|
||||
if (
|
||||
"knowledgeFile" in project
|
||||
and project.get("knowledgeFile") != "docs/ack/knowledge.yaml"
|
||||
@@ -829,8 +873,8 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
if source_workflow not in FEISHU_WORKFLOWS:
|
||||
errors.append(f"{where}.source.workflow: 非法")
|
||||
if (
|
||||
project_intake_workflow == "reviewed-writeback-v1"
|
||||
and source_workflow != "reviewed-writeback-v1"
|
||||
project_intake_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
and source_workflow != project_intake_workflow
|
||||
and status not in {"verified", "leftover"}
|
||||
):
|
||||
errors.append(
|
||||
@@ -838,24 +882,34 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
)
|
||||
approved_revision = source.get("approvedRevision")
|
||||
stored_payload_hash = source.get("approvedPayloadHash")
|
||||
if source_workflow == "reviewed-writeback-v1" and approved_revision is None:
|
||||
errors.append(f"{where}.source.approvedRevision: reviewed workflow 必填")
|
||||
is_approved_workflow = source_workflow in {"reviewed-writeback-v1", "clarified-writeback-v1"}
|
||||
if is_approved_workflow and approved_revision is None:
|
||||
errors.append(f"{where}.source.approvedRevision: writeback workflow 必填")
|
||||
elif approved_revision is not None and (
|
||||
not isinstance(approved_revision, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", approved_revision) is None
|
||||
):
|
||||
errors.append(f"{where}.source.approvedRevision: 必须是 sha256 revision")
|
||||
if source_workflow == "reviewed-writeback-v1":
|
||||
if is_approved_workflow:
|
||||
if (
|
||||
not isinstance(stored_payload_hash, str)
|
||||
or re.fullmatch(r"sha256:[0-9a-f]{64}", stored_payload_hash) is None
|
||||
):
|
||||
errors.append(f"{where}.source.approvedPayloadHash: reviewed workflow 必填")
|
||||
required_strings = ("title", "priority", "actual", "expected", "fixLogic")
|
||||
required_strings = (
|
||||
("title", "description", "actual", "expected")
|
||||
if source_workflow == "clarified-writeback-v1"
|
||||
else ("title", "priority", "actual", "expected", "fixLogic")
|
||||
)
|
||||
for field in required_strings:
|
||||
if not _nonempty_string(task.get(field)):
|
||||
errors.append(f"{where}.{field}: reviewed workflow 必须是非空字符串")
|
||||
for field in ("stepsToReproduce", "acceptanceCriteria"):
|
||||
required_lists = (
|
||||
("acceptanceCriteria",)
|
||||
if source_workflow == "clarified-writeback-v1"
|
||||
else ("stepsToReproduce", "acceptanceCriteria")
|
||||
)
|
||||
for field in required_lists:
|
||||
items = task.get(field)
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
@@ -959,6 +1013,72 @@ def validate_builtin(data: dict) -> list[str]:
|
||||
f"{where}.dispatch.rounds: round 必须从 1 连续递增且不重复"
|
||||
)
|
||||
|
||||
incidents = dispatch.get("environmentIncidents", [])
|
||||
if not isinstance(incidents, list):
|
||||
errors.append(f"{where}.dispatch.environmentIncidents: 必须是列表")
|
||||
else:
|
||||
seen_incident_ids: set[str] = set()
|
||||
for incident_index, incident in enumerate(incidents):
|
||||
incident_where = (
|
||||
f"{where}.dispatch.environmentIncidents[{incident_index}]"
|
||||
)
|
||||
if not isinstance(incident, dict):
|
||||
errors.append(f"{incident_where}: 必须是对象")
|
||||
continue
|
||||
reject_unknown_fields(
|
||||
incident,
|
||||
ENVIRONMENT_INCIDENT_FIELDS,
|
||||
incident_where,
|
||||
errors,
|
||||
)
|
||||
incident_id = incident.get("id")
|
||||
expected_id = (
|
||||
f"{tid}-ENV-{incident_index + 1}"
|
||||
if isinstance(tid, str)
|
||||
else None
|
||||
)
|
||||
if not isinstance(incident_id, str) or incident_id != expected_id:
|
||||
errors.append(f"{incident_where}.id: 应为 {expected_id}")
|
||||
elif incident_id in seen_incident_ids:
|
||||
errors.append(f"{incident_where}.id: 不能重复 {incident_id}")
|
||||
else:
|
||||
seen_incident_ids.add(incident_id)
|
||||
if incident.get("role") not in ENVIRONMENT_INCIDENT_ROLES:
|
||||
errors.append(
|
||||
f"{incident_where}.role: 必须是 coordinator/developer/test"
|
||||
)
|
||||
if incident.get("phase") not in ENVIRONMENT_INCIDENT_PHASES:
|
||||
errors.append(f"{incident_where}.phase: 非法环境阶段")
|
||||
incident_status = incident.get("status")
|
||||
if incident_status not in ENVIRONMENT_INCIDENT_STATUSES:
|
||||
errors.append(f"{incident_where}.status: 必须是 open/resolved")
|
||||
for field in (
|
||||
"summary",
|
||||
"evidence",
|
||||
"impact",
|
||||
"recoveryAction",
|
||||
"userAction",
|
||||
"reportedAt",
|
||||
):
|
||||
if not _nonempty_string(incident.get(field)):
|
||||
errors.append(f"{incident_where}.{field}: 必须是非空字符串")
|
||||
if "attemptId" in incident and not (
|
||||
incident["attemptId"] is None
|
||||
or _nonempty_string(incident["attemptId"])
|
||||
):
|
||||
errors.append(f"{incident_where}.attemptId: 必须是字符串或 null")
|
||||
if "resolvedAt" in incident and not (
|
||||
incident["resolvedAt"] is None
|
||||
or _nonempty_string(incident["resolvedAt"])
|
||||
):
|
||||
errors.append(f"{incident_where}.resolvedAt: 必须是字符串或 null")
|
||||
if incident_status == "resolved" and not _nonempty_string(
|
||||
incident.get("resolvedAt")
|
||||
):
|
||||
errors.append(
|
||||
f"{incident_where}: resolved 必须填写 resolvedAt"
|
||||
)
|
||||
|
||||
resolution = task.get("resolution")
|
||||
if "resolution" in task:
|
||||
if not isinstance(resolution, dict):
|
||||
|
||||
@@ -68,6 +68,7 @@ RECEIPT_FIELDS = frozenset(
|
||||
"receiptHash",
|
||||
}
|
||||
)
|
||||
RECEIPT_CONTEXT_FIELDS = frozenset({"projectRoot", "boardHash"})
|
||||
CREATED_FOR_FIELDS = frozenset({"taskId", "attemptId", "role"})
|
||||
WORKTREE_FIELDS = frozenset(
|
||||
{
|
||||
@@ -222,7 +223,6 @@ def validate_profile(profile: Any, *, where: str = "profile") -> list[str]:
|
||||
errors.append(
|
||||
f"{where}.permissionMode: must be read-only/workspace-write"
|
||||
)
|
||||
|
||||
if cli == "codex":
|
||||
if not isinstance(effort, str) or effort not in REASONING_EFFORTS:
|
||||
errors.append(
|
||||
@@ -613,8 +613,20 @@ def validate_worker_receipt(
|
||||
|
||||
if not isinstance(receipt, dict):
|
||||
return [f"{where}: must be an object"]
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS, where)
|
||||
errors = _unknown_fields(receipt, RECEIPT_FIELDS | RECEIPT_CONTEXT_FIELDS, where)
|
||||
errors.extend(_missing_fields(receipt, RECEIPT_FIELDS, where))
|
||||
project_root = receipt.get("projectRoot")
|
||||
board_hash = receipt.get("boardHash")
|
||||
if (project_root is None) != (board_hash is None):
|
||||
errors.append(f"{where}: projectRoot and boardHash must be present together")
|
||||
if project_root is not None and (
|
||||
not isinstance(project_root, str) or not project_root.startswith("/")
|
||||
):
|
||||
errors.append(f"{where}.projectRoot: must be an absolute path")
|
||||
if board_hash is not None and (
|
||||
not isinstance(board_hash, str) or SHA256_RE.fullmatch(board_hash) is None
|
||||
):
|
||||
errors.append(f"{where}.boardHash: must be a canonical sha256 hex digest")
|
||||
|
||||
version = receipt.get("receiptVersion")
|
||||
if version != RECEIPT_VERSION or isinstance(version, bool):
|
||||
@@ -728,18 +740,20 @@ def validate_worker_receipt(
|
||||
and isinstance(requested, dict)
|
||||
):
|
||||
try:
|
||||
expected_fingerprint = canonical_sha256(
|
||||
{
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
)
|
||||
facts = {
|
||||
"protocolVersion": LAUNCH_PROTOCOL_VERSION,
|
||||
"backend": "orca",
|
||||
"profileId": receipt.get("profileId"),
|
||||
"profileHash": receipt.get("profileHash"),
|
||||
"createdFor": created_for,
|
||||
"worktree": worktree,
|
||||
"requested": requested,
|
||||
"slot": slot,
|
||||
}
|
||||
if project_root is not None and board_hash is not None:
|
||||
facts["projectRoot"] = project_root
|
||||
facts["boardHash"] = board_hash
|
||||
expected_fingerprint = canonical_sha256(facts)
|
||||
except ValueError:
|
||||
errors.append(f"{where}.launchFingerprint: cannot hash launch facts")
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user