#!/usr/bin/env python3 """YunxiaoPM 极速真实建单 v5:快轨描述/计划/标签/工时 2+2/设计 ASSOCIATED 补挂。""" from __future__ import annotations import json import time import urllib.parse from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any try: import browser_cookie3 except ImportError: # pragma: no cover browser_cookie3 = None import requests ROOT = Path(__file__).resolve().parents[1] RUNTIME = json.loads((ROOT / "assets" / "runtime-ids.json").read_text()) STATUS = RUNTIME["status"]["req"] TASK_STATUS = RUNTIME["status"]["task"] FAST = RUNTIME.get("fields", {}).get("fast_track") or {} SPACE = ( RUNTIME.get("project", {}).get("spaceIdentifier") or (RUNTIME.get("project", {}).get("last_selected") or {}).get("spaceIdentifier") ) if not SPACE: raise RuntimeError( "未选定项目 spaceIdentifier:须先走门禁 PJ 点选,或设置 runtime project.last_selected" ) REQ_TYPE = RUNTIME["workitem_types"]["product_req"]["identifier"] TASK_TYPE = RUNTIME["workitem_types"]["task"]["identifier"] HEFEI = RUNTIME["people"]["hefei"]["identifier"] WANG = RUNTIME["people"].get("wangmian", {}).get("identifier", "6811df000601d2fea60144a9") PRI = RUNTIME["priority"]["中"] TAG_FAULT = RUNTIME.get("tags", {}).get("故障管理", "ceb526a7343995577645317e9a") PLACEHOLDER = RUNTIME["delivery_placeholder"] PROTO = "https://prototype.lnoneos.com/vehicle-fault-handling/index.html" TZ = timezone(timedelta(hours=8)) TODAY = datetime.now(TZ).strftime("%Y-%m-%d") NOON = f"{TODAY} 12:00:00" EOD = f"{TODAY} 23:59:59" NOON_MS = str( int(datetime.now(TZ).replace(hour=12, minute=0, second=0, microsecond=0).timestamp() * 1000) ) START_MS = str( int(datetime.now(TZ).replace(hour=9, minute=0, second=0, microsecond=0).timestamp() * 1000) ) END_MS = str( int(datetime.now(TZ).replace(hour=11, minute=0, second=0, microsecond=0).timestamp() * 1000) ) FAST_EST = int(FAST.get("req_estimated_hours", 2)) FAST_ACT = int(FAST.get("req_actual_hours", 2)) PENDING = STATUS["待处理"] DESIGN_DONE = STATUS["设计完成"] PENDING_DEV = STATUS["待开发"] TASK_DONE = TASK_STATUS["已完成"] _COOKIE = "" _XSRF = "" def load_auth() -> None: global _COOKIE, _XSRF jar: dict[str, str] = {} if browser_cookie3: for domain in (".aliyun.com", "devops.aliyun.com", ".devops.aliyun.com"): try: for c in browser_cookie3.chrome(domain_name=domain): jar[c.name] = c.value except Exception: pass if not jar: p = Path("/tmp/yunxiao_cookies.json") if p.exists(): raw = json.loads(p.read_text()) jar = ( raw if isinstance(raw, dict) and "XSRF-TOKEN" in raw else {c["name"]: c["value"] for c in raw.get("cookies", [])} ) _COOKIE = "; ".join(f"{k}={v}" for k, v in jar.items()) x = jar.get("XSRF-TOKEN", "") _XSRF = urllib.parse.unquote(x) if "%" in x else x if not _XSRF: raise RuntimeError("缺少 XSRF-TOKEN:请先在 Chrome 登录 devops.aliyun.com") def session() -> requests.Session: s = requests.Session() s.headers.update( { "Content-Type": "application/json", "Cookie": _COOKIE, "x-xsrf-token": _XSRF, "X-XSRF-TOKEN": _XSRF, "Origin": "https://devops.aliyun.com", "Referer": f"https://devops.aliyun.com/projex/project/{SPACE}/req", "accept": "application/json", "User-Agent": "YunxiaoPM-live_create_fast/5.0", "Connection": "keep-alive", } ) return s def api(s: requests.Session, method: str, url: str, body: Any = None) -> dict: r = s.request(method, url, json=body, timeout=60) try: return r.json() except Exception: r.raise_for_status() raise def create(s: requests.Session, payload: dict) -> dict: url = "https://devops.aliyun.com/projex/api/workitem/workitem?_input_charset=utf-8" j = api(s, "POST", url, payload) r = j.get("result") or {} if j.get("code") == 200 and isinstance(r, dict) and r.get("identifier"): return r j = api(s, "PUT", url, payload) r = j.get("result") or {} if j.get("code") == 200 and isinstance(r, dict) and r.get("identifier"): return r raise RuntimeError(f"create failed: {j}") def get(s: requests.Session, wid: str) -> dict: return api( s, "GET", f"https://devops.aliyun.com/projex/api/workitem/workitem/{wid}?_input_charset=utf-8", )["result"] def apply_tag(s: requests.Session, wid: str, tag_id: str = TAG_FAULT) -> None: j = api( s, "PATCH", f"https://devops.aliyun.com/projex/api/workitem/workitem/{wid}?_input_charset=utf-8", { "workitemIdentifier": wid, "propertyKey": "tag", "propertyValue": tag_id, "operateType": "COVER", }, ) if j.get("code") != 200: raise RuntimeError(f"tag failed {wid}: {j}") def set_document(s: requests.Session, wid: str, html: str) -> None: j = api( s, "PATCH", f"https://devops.aliyun.com/projex/api/workitem/workitem/{wid}/document?_input_charset=utf-8", {"content": html, "formatType": "RICHTEXT"}, ) if j.get("code") != 200 or j.get("errorMsg"): raise RuntimeError(f"document failed {wid}: {j}") def set_fields(s: requests.Session, wid: str, pairs: list[tuple[str, str]]) -> None: data = urllib.parse.urlencode( { "fieldValueList": json.dumps( [{"fieldIdentifier": k, "value": v} for k, v in pairs], ensure_ascii=False ) } ) r = s.post( f"https://devops.aliyun.com/projex/api/workitem/workitem/field/value/{wid}?_input_charset=utf-8", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=60, ) j = r.json() if j.get("code") != 200: raise RuntimeError(f"field/value failed {wid}: {j}") def set_estimate_hours(s: requests.Session, wid: str, hours: int, user: str = WANG) -> None: est = api( s, "GET", f"https://devops.aliyun.com/projex/api/workitem/workitem/time/estimate/list?workitemIdentifier={wid}", ).get("result") or [] for row in est: eid = row.get("identifier") if eid: api( s, "DELETE", f"https://devops.aliyun.com/projex/api/workitem/workitem/time/estimate/{wid}/{eid}", ) j = api( s, "POST", "https://devops.aliyun.com/projex/api/workitem/workitem/time/estimate?_input_charset=utf-8", { "workitemIdentifier": wid, "spentTime": hours, "type": "develop", "description": "快轨默认预计工时", "recordUserIdentifier": user, "forCreate": False, "containsRestDay": False, }, ) if j.get("code") != 200: raise RuntimeError(f"estimate failed {wid}: {j}") def set_actual_hours(s: requests.Session, wid: str, hours: int, user: str = WANG) -> None: j = api( s, "POST", "https://devops.aliyun.com/projex/api/workitem/workitem/time?_input_charset=utf-8", { "workitemIdentifier": wid, "actualTime": hours, "type": "develop", "description": "快轨默认实际工时", "recordUserIdentifier": user, "gmtStart": START_MS, "gmtEnd": END_MS, }, ) if j.get("code") != 200 or not j.get("result"): raise RuntimeError(f"actual time failed {wid}: {j}") def try_associate_to_req(s: requests.Session, stage_id: str, req_id: str) -> bool: """建后补 ASSOCIATED→需求。Cookie 下常失败,成功返回 True。""" bodies = [ {"relationIdentifier": "ASSOCIATED", "toWorkitemIdentifier": req_id}, { "relationIdentifier": "ASSOCIATED", "fromWorkitemIdentifier": stage_id, "toWorkitemIdentifier": req_id, }, ] for body in bodies: for url in ( f"https://devops.aliyun.com/projex/api/workitem/workitem/{stage_id}/relation/record?_input_charset=utf-8", f"https://devops.aliyun.com/projex/api/workitem/v2/workitem/{stage_id}/relation/record?_input_charset=utf-8", ): j = api(s, "POST", url, body) if j.get("code") == 200 and j.get("result") not in (None, False): if not (isinstance(j.get("result"), dict) and j["result"].get("status") in (404, 405)): rows = list_associated(s, stage_id) if req_id in {r.get("identifier") for r in rows}: return True return False def transit(s: requests.Session, wid: str, from_status: str, to_status: str) -> None: if from_status == to_status: return j = api( s, "POST", f"https://devops.aliyun.com/projex/api/workitem/workitem/{wid}/status/transit?_input_charset=utf-8", {"fromStatus": from_status, "toStatus": to_status}, ) if not (j.get("code") == 200 and j.get("result") is True): raise RuntimeError(f"transit {wid} {from_status}->{to_status}: {j}") def md_to_html(md: str) -> str: parts = [] for line in md.splitlines(): if line.startswith("## "): parts.append(f"
• {line[2:]}
") elif line.strip(): parts.append(f"{line}
") return "".join(parts) def req_document_html(s: requests.Session, rid: str) -> str: w = get(s, rid) return ((w.get("document") or {}).get("content") or w.get("description") or "").strip() def req_payload(subject: str, html: str) -> dict: return { "subject": subject, "description": html, "formatType": "RICHTEXT", "document": {"content": html, "formatType": "RICHTEXT"}, "spaceIdentifier": SPACE, "space": SPACE, "spaceType": "Project", "workitemTypeIdentifier": REQ_TYPE, "workitemType": REQ_TYPE, "categoryIdentifier": "Req", "category": "Req", "assignedTo": WANG, "fieldValueList": [ {"fieldIdentifier": "priority", "value": PRI}, {"fieldIdentifier": "assignedTo", "value": WANG}, ], "attachmentIdList": [], "cloneFrom": None, "createWorkitemRelationList": [], } def task_payload( subject: str, html: str, assignee: str, *, plan_start: bool = True, associated_req: str | None = None, parent_delivery: str | None = None, ) -> dict: fvl = [ {"fieldIdentifier": "priority", "value": PRI}, {"fieldIdentifier": "assignedTo", "value": assignee}, ] if plan_start: fvl.append({"fieldIdentifier": "79", "value": NOON_MS}) payload: dict[str, Any] = { "subject": subject, "description": html, "formatType": "RICHTEXT", "spaceIdentifier": SPACE, "space": SPACE, "spaceType": "Project", "workitemTypeIdentifier": TASK_TYPE, "workitemType": TASK_TYPE, "categoryIdentifier": "Task", "category": "Task", "assignedTo": assignee, "fieldValueList": fvl, "attachmentIdList": [], "cloneFrom": None, } if parent_delivery: payload["parent"] = parent_delivery payload["parentIdentifier"] = parent_delivery payload["createWorkitemRelationInfo"] = { "relatedWorkitemIdentifier": parent_delivery, "relatedToRelationIdentifier": "TASK_SUB", } else: if not associated_req: raise ValueError("associated_req required for delivery: ASSOCIATED→需求") payload["createWorkitemRelationInfo"] = { "relatedWorkitemIdentifier": associated_req, "relatedToRelationIdentifier": "ASSOCIATED", } return payload def list_associated(s: requests.Session, wid: str) -> list[dict]: j = api( s, "GET", f"https://devops.aliyun.com/projex/api/workitem/v2/workitem/{wid}/relation/workitem/list/by-relation-category?category=ASSOCIATED&isForward=true", ) return j.get("result") or [] def assert_associated_to_req(s: requests.Session, wid: str, req_id: str, label: str) -> None: rows = list_associated(s, wid) ids = {r.get("identifier") for r in rows} if req_id not in ids: raise RuntimeError( f"{label} 关联项未挂需求:期望 {req_id},实际 {[r.get('serialNumber') for r in rows]}" ) AUTO_RDO = """## 原始诉求(AutoRDO) 运维需在故障处置页承接机器人上报的故障,完成处置、挂起与归档,并保留证据链;工作台相关统计口径需与处置页一致 待确认: - 本期是否含真实短信/邮件通道(现口径一般为演示模板) ## 工作项编号(系统) - 交付:待建 - 分析:待建 - 设计:待建 """ def summarize_from_create(w: dict, *, status: str, assignee_name: str) -> dict: return { "serial": w.get("serialNumber"), "id": w.get("identifier"), "subject": w.get("subject"), "status": status, "assignee": assignee_name, "parent": w.get("parentIdentifier"), } def build_normal() -> dict: t0 = time.perf_counter() s = session() title = "【新增】故障处置(YunxiaoPMapp标准·极速v2)" req = create(s, req_payload(title, md_to_html(AUTO_RDO + f"\n原型:{PROTO}\n"))) rid = req["identifier"] with ThreadPoolExecutor(max_workers=2) as pool: f_tag_r = pool.submit(apply_tag, session(), rid) f_deliv = pool.submit( create, session(), task_payload( f"【交付】{title}", f"{PLACEHOLDER}
", HEFEI, associated_req=rid, ), ) deliv = f_deliv.result() f_tag_r.result() did = deliv["identifier"] with ThreadPoolExecutor(max_workers=3) as pool: f_tag_d = pool.submit(apply_tag, session(), did) f_ana = pool.submit( create, session(), task_payload( f"【分析】{title}", "分析阶段:故障处置台账、挂起归档与证据链。
", WANG, associated_req=rid, parent_delivery=did, ), ) f_des = pool.submit( create, session(), task_payload( f"【设计】{title}", f"设计阶段:对齐原型 {PROTO}
", WANG, associated_req=rid, parent_delivery=did, ), ) ana = f_ana.result() des = f_des.result() f_tag_d.result() with ThreadPoolExecutor(max_workers=3) as pool: list( as_completed( [ pool.submit(transit, session(), rid, PENDING, DESIGN_DONE), pool.submit(transit, session(), ana["identifier"], PENDING, TASK_DONE), pool.submit(transit, session(), des["identifier"], PENDING, TASK_DONE), ] ) ) transit(s, rid, DESIGN_DONE, PENDING_DEV) return { "path": "normal", "elapsed_s": round(time.perf_counter() - t0, 3), "req": summarize_from_create(req, status="待开发", assignee_name="王冕"), "delivery": summarize_from_create(deliv, status="待处理", assignee_name="何斐"), "analysis": summarize_from_create(ana, status="已完成", assignee_name="王冕"), "design": summarize_from_create(des, status="已完成", assignee_name="王冕"), } def build_fast( *, tag_id: str = TAG_FAULT, has_prototype: bool = True, delivery_html: str | None = None, ) -> dict: """无单快轨。 - 设计描述 = 需求 document - 交付描述 = 手工同步需求正文(无原型)或传入 AutoPRD HTML;禁止无故占位 - 设计 79/80=当日;交付 79=当日 - 需求/交付/设计同标签 - 需求预计/实际工时各 2 - 设计 TASK_SUB→交付后尝试 ASSOCIATED→需求 """ t0 = time.perf_counter() s = session() title = "【新增】故障处置(YunxiaoPM快轨·极速v5)" req_html = md_to_html(AUTO_RDO + (f"\n原型:{PROTO}\n" if has_prototype else "\n")) req = create(s, req_payload(title, req_html)) rid = req["identifier"] # 以落库 document 为准(与手动建需后读需求一致) req_html = req_document_html(s, rid) or req_html if delivery_html: deliv_html = delivery_html desc_source = "autoprd" elif req_html.strip(): deliv_html = req_html desc_source = "manual_sync" else: deliv_html = f"{PLACEHOLDER}
" desc_source = "placeholder" with ThreadPoolExecutor(max_workers=2) as pool: f_tag_r = pool.submit(apply_tag, session(), rid, tag_id) f_deliv = pool.submit( create, session(), task_payload( f"【交付】{title}", deliv_html, HEFEI, associated_req=rid, ), ) deliv = f_deliv.result() f_tag_r.result() did = deliv["identifier"] with ThreadPoolExecutor(max_workers=2) as pool: f_tag_d = pool.submit(apply_tag, session(), did, tag_id) f_des = pool.submit( create, session(), task_payload( f"【设计】{title}", req_html, WANG, parent_delivery=did, ), ) des = f_des.result() f_tag_d.result() des_id = des["identifier"] apply_tag(s, des_id, tag_id) # 计划时间:设计起止当日;交付开始当日(create 已带 79,再 field/value 加固) set_fields(s, des_id, [("79", NOON), ("80", EOD)]) set_fields(s, did, [("79", NOON)]) # 设计关联项补挂需求(可能失败,回报 risk) design_assoc_ok = try_associate_to_req(s, des_id, rid) with ThreadPoolExecutor(max_workers=2) as pool: list( as_completed( [ pool.submit(transit, session(), rid, PENDING, DESIGN_DONE), pool.submit(transit, session(), des_id, PENDING, TASK_DONE), ] ) ) transit(s, rid, DESIGN_DONE, PENDING_DEV) set_estimate_hours(s, rid, FAST_EST) set_actual_hours(s, rid, FAST_ACT) risk = None if desc_source == "placeholder": risk = "交付描述仍为占位" if not design_assoc_ok: risk = (risk + ";" if risk else "") + "设计 ASSOCIATED→需求补挂失败(Cookie),须 UI/OpenAPI 兜底" return { "path": "fast", "elapsed_s": round(time.perf_counter() - t0, 3), "req": summarize_from_create(req, status="待开发", assignee_name="王冕"), "delivery": summarize_from_create(deliv, status="待处理", assignee_name="何斐"), "design": summarize_from_create(des, status="已完成", assignee_name="王冕"), "analysis": None, "delivery_desc_source": desc_source, "design_associated_ok": design_assoc_ok, "risk": risk, } def main() -> None: auth0 = time.perf_counter() load_auth() auth_s = round(time.perf_counter() - auth0, 3) wall0 = time.perf_counter() with ThreadPoolExecutor(max_workers=2) as pool: f_fast = pool.submit(build_fast) f_normal = pool.submit(build_normal) fast = f_fast.result() normal = f_normal.result() build_s = round(time.perf_counter() - wall0, 3) s = session() assert_associated_to_req(s, normal["delivery"]["id"], normal["req"]["id"], "normal.delivery") assert_associated_to_req(s, fast["delivery"]["id"], fast["req"]["id"], "fast.delivery") def assert_sub(delivery_id: str, child_id: str, label: str) -> None: rows = api( s, "GET", f"https://devops.aliyun.com/projex/api/workitem/v2/workitem/{delivery_id}/relation/workitem/list/by-relation-category?category=PARENT_SUB&isForward=true", ).get("result") or [] ids = {r.get("identifier") for r in rows} if child_id not in ids: raise RuntimeError( f"{label} 未出现在交付子项:期望 {child_id},实际 {[r.get('serialNumber') for r in rows]}" ) assert_sub(normal["delivery"]["id"], normal["analysis"]["id"], "normal.analysis") assert_sub(normal["delivery"]["id"], normal["design"]["id"], "normal.design") assert_sub(fast["delivery"]["id"], fast["design"]["id"], "fast.design") verify = { "normal_req": get(s, normal["req"]["id"])["status"]["displayName"], "fast_req": get(s, fast["req"]["id"])["status"]["displayName"], "normal_delivery_assignee": ( get(s, normal["delivery"]["id"]).get("assignedTo") or {} ).get("displayName"), "fast_delivery_assignee": (get(s, fast["delivery"]["id"]).get("assignedTo") or {}).get( "displayName" ), "delivery_associated_ok": True, "stage_tasks_as_sub_ok": True, "fast_design_associated_ok": fast.get("design_associated_ok"), } out = { "normal": normal, "fast": fast, "verify": verify, "auth_elapsed_s": auth_s, "wall_elapsed_s": build_s, "created_at": datetime.now(TZ).isoformat(), "mode": "live_create_fast_v5_fast_track_rules", "opts": { "create_includes_plan_start": True, "skip_plan_end_on_create": True, "tracked_transit_no_get": True, "delivery_assignee_hefei_at_create": True, "overlap_tag_with_create": True, "fast_req_hours": f"{FAST_EST}+{FAST_ACT}", "relation": "delivery ASSOCIATED→req; analysis/design TASK_SUB→delivery; design post ASSOCIATED→req", "http": "requests.Session keep-alive", }, } Path("/tmp/yunxiao_pmapp_fast_v2_result.json").write_text( json.dumps(out, ensure_ascii=False, indent=2) ) print(json.dumps(out, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()