151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Build structured tester issues and the development feedback route.
|
|
|
|
This module only writes the run contract. Updating a remote PR/issue or
|
|
notifying a dispatcher remains an explicit caller-side integration step; no
|
|
network mutation is hidden in the tester.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
|
|
ISSUE_TYPES = ("product", "test")
|
|
TRIGGERS = ("pr", "requirement", "bug")
|
|
ISSUE_RESULTS = frozenset({"fail", "environment-fail"})
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def issue_type_for(result: str) -> str:
|
|
if result == "fail":
|
|
return "product"
|
|
if result == "environment-fail":
|
|
return "test"
|
|
raise ValueError(f"只有 fail/environment-fail 可以生成 issue: {result}")
|
|
|
|
|
|
def _value(row: Mapping[str, Any], key: str, defaults: Mapping[str, Any]) -> Any:
|
|
value = row.get(key)
|
|
return value if value not in (None, "") else defaults.get(key, "")
|
|
|
|
|
|
def build_issue(
|
|
row: Mapping[str, Any],
|
|
*,
|
|
defaults: Mapping[str, Any] | None = None,
|
|
issue_type: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create the stable issue shape required by the run report."""
|
|
defaults = defaults or {}
|
|
result = str(row.get("result") or "")
|
|
if result not in ISSUE_RESULTS:
|
|
raise ValueError(f"不能为 {result!r} 结果创建 defect: {row.get('id')}")
|
|
selected_type = issue_type or row.get("issue_type") or issue_type_for(result)
|
|
if selected_type not in ISSUE_TYPES:
|
|
raise ValueError(f"issue_type 必须是 product/test: {selected_type}")
|
|
testcase = str(row.get("testcase") or row.get("id") or "")
|
|
if not testcase:
|
|
raise ValueError("issue 缺少 testcase")
|
|
return {
|
|
"id": str(row.get("issue_id") or f"TEST-{testcase}-{result}"),
|
|
"testcase": testcase,
|
|
"issue_type": selected_type,
|
|
"result": result,
|
|
"phase": _value(row, "phase", defaults),
|
|
"expected": _value(row, "expected", defaults),
|
|
"observed": _value(row, "observed", defaults),
|
|
"artifact_hash": _value(row, "artifact_hash", defaults),
|
|
"environment": _value(row, "environment", defaults),
|
|
"evidence_path": _value(row, "evidence_path", defaults),
|
|
"reproduction": _value(row, "reproduction", defaults),
|
|
"next_step": _value(row, "next_step", defaults),
|
|
"created_at": str(row.get("created_at") or _now()),
|
|
}
|
|
|
|
|
|
def collect_issues(
|
|
results: Iterable[Mapping[str, Any]],
|
|
*,
|
|
defaults: Mapping[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
issues: list[dict[str, Any]] = []
|
|
for row in results:
|
|
if not isinstance(row, Mapping) or str(row.get("result") or "") not in ISSUE_RESULTS:
|
|
continue
|
|
explicit = row.get("issue")
|
|
source = dict(row)
|
|
if isinstance(explicit, Mapping):
|
|
source.update(explicit)
|
|
issues.append(build_issue(source, defaults=defaults))
|
|
return issues
|
|
|
|
|
|
def feedback_plan(
|
|
trigger: str,
|
|
*,
|
|
target: str = "",
|
|
related_issue: str = "",
|
|
dispatcher: str = "dispatcher",
|
|
) -> dict[str, Any]:
|
|
"""Describe the required handoff without performing remote mutations."""
|
|
if trigger not in TRIGGERS:
|
|
raise ValueError(f"trigger 必须是 {', '.join(TRIGGERS)}")
|
|
if trigger == "pr":
|
|
actions = [
|
|
{"action": "update_pr", "target": target, "status": "required"},
|
|
{"action": "update_related_issue", "target": related_issue, "status": "required"},
|
|
{"action": "notify_dispatcher", "target": dispatcher, "status": "required"},
|
|
{"action": "reassign_development", "target": dispatcher, "status": "pending"},
|
|
]
|
|
route = "pr-update-issue-dispatcher-reassign"
|
|
else:
|
|
actions = [
|
|
{"action": "return_to_development", "target": target or trigger, "status": "required"},
|
|
]
|
|
route = "development"
|
|
return {
|
|
"trigger": trigger,
|
|
"route": route,
|
|
"target": target,
|
|
"related_issue": related_issue,
|
|
"actions": actions,
|
|
"remote_mutation": "caller-required",
|
|
"created_at": _now(),
|
|
}
|
|
|
|
|
|
def attach_feedback(
|
|
run: Mapping[str, Any],
|
|
*,
|
|
trigger: str | None = None,
|
|
target: str = "",
|
|
related_issue: str = "",
|
|
dispatcher: str = "dispatcher",
|
|
defaults: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return a new run mapping with results, defects and routing preserved."""
|
|
output = deepcopy(dict(run))
|
|
results = output.get("results")
|
|
if not isinstance(results, list):
|
|
raise ValueError("run 缺少 results 列表")
|
|
issues = collect_issues(results, defaults=defaults)
|
|
output["defects"] = issues
|
|
# Compatibility name for consumers that call the same structure
|
|
# ``test-issues``. It is deliberately a copy, not a second source of truth.
|
|
output["test_issues"] = deepcopy(issues)
|
|
if trigger:
|
|
output["feedback"] = feedback_plan(
|
|
trigger,
|
|
target=target,
|
|
related_issue=related_issue,
|
|
dispatcher=dispatcher,
|
|
)
|
|
return output
|
|
|