"""测试计划能力 — sd_test_plans 的状态机语义化迁移(通用、产线无关)。 定位:测试计划 CRUD 走 xls2ui 生成的端点;本模块只做「生命周期状态流转」—— 每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。 测试计划的生命周期/流转规则在 test-plan skill 里(LLM 读 skill 判断合法性);本模块只固化操作原语。 角色规范:role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}。 scope 约定:sd_test_plans 无 project_id 列,用 iteration_id 做范围校验(CAS 的 WHERE 里带 iteration_id)。 """ import logging from sqlor.dbpools import DBPools from appPublic.uniqueID import getID from .audit import record_audit DBNAME = "pipeline" logger = logging.getLogger("pipeline.test_plan_capability") TABLE = "sd_test_plans" # 测试计划状态(SDLC 默认,状态机语义见 test-plan skill) S_DRAFT = "draft" # 草稿 S_APPROVED = "approved" # 已审批 S_EXECUTING = "executing" # 执行中 S_COMPLETED = "completed" # 已完成 def _get_db(): db = DBPools() if not db.databases: from appPublic.jsonConfig import getConfig config = getConfig() if config.databases: db.databases = config.databases return db, DBNAME def _normalize_role(role): """角色规范:agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 保留原样。""" role = (role or "").strip() if not role: return "" if "." in role: return role return f"agent.{role}" async def _transition(plan_id, iteration_id, from_state, to_state, action, who=None, agent_id=None, detail=None): """CAS 状态迁移 + 审计。返回 (ok, message)。""" if not plan_id or not iteration_id: return False, "缺少 plan_id 或 iteration_id" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: await sor.sqlExe( f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() " "WHERE id=${pid}$ AND iteration_id=${iid}$ AND status=${from}$", {"to": to_state, "pid": plan_id, "iid": iteration_id, "from": from_state}) recs = await sor.R(TABLE, {'id': plan_id}) await sor.sqlExe("COMMIT", {}) if not recs: return False, "测试计划不存在" cur = getattr(recs[0], 'status', '') if cur != to_state: return False, f"状态迁移失败(CAS): 期望 from={from_state} 实际 status={cur}" await record_audit(iteration_id, TABLE, plan_id, action, from_state=from_state, to_state=to_state, who=who, agent_id=agent_id, detail=detail, sor=sor) return True, to_state async def create_test_plan(iteration_id, plan_name, plan_type="functional", scope="", environment="", entry_criteria="", exit_criteria="", created_by="", who=None, agent_id=None): """创建测试计划:新建记录,status=draft。返回 (ok, plan_id_or_message)。""" if not iteration_id: return False, "缺少 iteration_id" if not plan_name or not plan_name.strip(): return False, "缺少 plan_name" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: pid = getID() await sor.C(TABLE, { 'id': pid, 'iteration_id': iteration_id, 'plan_name': plan_name.strip(), 'plan_type': plan_type or 'functional', 'scope': scope or '', 'environment': environment or '', 'entry_criteria': entry_criteria or '', 'exit_criteria': exit_criteria or '', 'status': S_DRAFT, 'created_by': created_by or '', }) await record_audit(iteration_id, TABLE, pid, 'create', to_state=S_DRAFT, who=_normalize_role(who), agent_id=agent_id, sor=sor) logger.info("create_test_plan: %s iteration=%s", pid, iteration_id) return True, pid async def approve_plan(plan_id, iteration_id, who=None, agent_id=None): """审批通过:draft → approved。""" return await _transition(plan_id, iteration_id, S_DRAFT, S_APPROVED, 'approve', who=who, agent_id=agent_id) async def start_plan(plan_id, iteration_id, who=None, agent_id=None): """开始执行:approved → executing。""" return await _transition(plan_id, iteration_id, S_APPROVED, S_EXECUTING, 'start', who=who, agent_id=agent_id) async def complete_plan(plan_id, iteration_id, who=None, agent_id=None): """完成测试:executing → completed。""" return await _transition(plan_id, iteration_id, S_EXECUTING, S_COMPLETED, 'complete', who=who, agent_id=agent_id) async def set_plan_state(plan_id, iteration_id, from_state, to_state, who=None, agent_id=None, detail=None): """通用 CAS 状态迁移兜底(跨产线自定义状态机用)。""" return await _transition(plan_id, iteration_id, from_state, to_state, 'set_state', who=who, agent_id=agent_id, detail=detail) async def list_plans(iteration_id, status=None, limit=50) -> list: """列出测试计划(可按状态过滤)。""" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: conditions = ["iteration_id=${iid}$"] params = {"iid": iteration_id} if status: conditions.append("status=${status}$") params["status"] = status where = " AND ".join(conditions) try: limit = int(limit) except (TypeError, ValueError): limit = 50 sql = (f"SELECT * FROM {TABLE} WHERE {where} " f"ORDER BY created_at ASC LIMIT {limit}") recs = await sor.sqlExe(sql, params) # 释放 SELECT 元数据锁 await sor.sqlExe("COMMIT", {}) result = [] for rec in (recs or []): result.append(_rec_to_dict(rec)) return result def _rec_to_dict(rec): """把 sqlor 记录对象转成 dict(sqlor 行是 DictObject,必须 dict(rec) 取列)。""" if isinstance(rec, dict): return dict(rec) try: return dict(rec) except (TypeError, ValueError): if hasattr(rec, 'to_dict'): try: return rec.to_dict() except Exception: pass return {}