新增 AutoRDO 需求清洗工作台与消息中枢,迭代 OneOS V2 设计规范及租赁合同/工作台/车辆等原型,同步云效技能与导航注册;并归档一批 legacy 原型快照。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-28 15:50:35 +08:00
parent b14425da5a
commit 5d51a6bf7a
1393 changed files with 488042 additions and 10046 deletions

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""拉取云效项目列表(供 YunxiaoPMapp 门禁 PJ 点选。stdout = JSON。
无法对应时自动重拉一次:
python3 scripts/list_projects.py --match '01_ONEOS'
python3 scripts/list_projects.py --match-id 1280be963a5a2cc126a4118dca
"""
from __future__ import annotations
import argparse
import json
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import requests
ROOT = Path(__file__).resolve().parents[1]
RUNTIME = json.loads((ROOT / "assets" / "runtime-ids.json").read_text())
ORG = RUNTIME.get("project", {}).get("organizationIdentifier", "697c54a19df7fdfa65466405")
WANG = RUNTIME.get("people", {}).get("wangmian", {}).get("identifier", "6811df000601d2fea60144a9")
ALIASES = [a.lower() for a in (RUNTIME.get("project", {}).get("name_aliases") or [])]
TZ = timezone(timedelta(hours=8))
def load_jar() -> dict[str, str]:
jar: dict[str, str] = {}
try:
import 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
except Exception:
pass
if not jar.get("XSRF-TOKEN"):
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", [])}
)
if not jar.get("XSRF-TOKEN"):
raise RuntimeError("缺少 XSRF-TOKEN请先在 Chrome 登录 devops.aliyun.com")
return jar
def fetch_projects() -> list[dict[str, Any]]:
jar = load_jar()
x = jar.get("XSRF-TOKEN", "")
xsrf = urllib.parse.unquote(x) if "%" in x else x
cookie = "; ".join(f"{k}={v}" for k, v in jar.items())
headers = {
"Cookie": cookie,
"x-xsrf-token": xsrf,
"X-XSRF-TOKEN": xsrf,
"Origin": "https://devops.aliyun.com",
"Referer": "https://devops.aliyun.com/projex",
"accept": "application/json",
}
extra = json.dumps(
{
"conditionGroups": [
[
{
"className": "user",
"fieldIdentifier": "users",
"format": "multiList",
"operator": "CONTAINS",
"value": [WANG],
}
],
[
{
"className": "string",
"fieldIdentifier": "scope",
"format": "list",
"operator": "CONTAINS",
"value": ["public"],
}
],
]
},
separators=(",", ":"),
)
params = {
"extraConditions": extra,
"conditions": json.dumps({"conditionGroups": [[]]}, separators=(",", ":")),
"orderBy": json.dumps(
{
"fieldIdentifier": "gmtCreate",
"format": "input",
"order": "desc",
"className": "date",
},
separators=(",", ":"),
),
"scope": "all",
"category": "Project",
"toPage": 1,
"pageSize": 100,
"_input_charset": "utf-8",
}
r = requests.get(
"https://devops.aliyun.com/projex/api/workspace/project/search/list",
headers=headers,
params=params,
timeout=30,
)
r.raise_for_status()
rows = r.json().get("result") or []
return [
{
"name": p.get("name"),
"identifier": p.get("identifier"),
"customCode": p.get("customCode"),
"logicalStatus": p.get("logicalStatus"),
"status": (p.get("status") or {}).get("displayName"),
"scope": p.get("scope"),
}
for p in rows
]
def match_projects(
projects: list[dict[str, Any]],
*,
query: str | None = None,
space_id: str | None = None,
) -> list[dict[str, Any]]:
if space_id:
sid = space_id.strip()
return [p for p in projects if p.get("identifier") == sid]
if not query:
return []
q = query.strip().lower()
hits: list[dict[str, Any]] = []
for p in projects:
name = (p.get("name") or "").lower()
code = (p.get("customCode") or "").lower()
if q == name or q == code or q in name or name in q:
hits.append(p)
continue
if q in ALIASES and (code == "oneos" or "oneos" in name or "运营" in name):
hits.append(p)
# de-dupe by identifier
seen: set[str] = set()
out: list[dict[str, Any]] = []
for p in hits:
i = p.get("identifier") or ""
if i in seen:
continue
seen.add(i)
out.append(p)
return out
def list_projects(
*,
match: str | None = None,
match_id: str | None = None,
) -> dict:
projects = fetch_projects()
payload: dict[str, Any] = {
"fetched_at": datetime.now(TZ).isoformat(),
"organizationIdentifier": ORG,
"count": len(projects),
"projects": projects,
"selection_required": True,
"refetched": False,
"note": "禁止默认直指;须 AskQuestion/Plan 点选后再写入 spaceIdentifier",
}
if not match and not match_id:
return payload
hits = match_projects(projects, query=match, space_id=match_id)
if len(hits) == 1:
payload["matches"] = hits
payload["match_status"] = "unique"
return payload
# 无法对应0 或多)→ 自动重拉一次
projects2 = fetch_projects()
hits2 = match_projects(projects2, query=match, space_id=match_id)
payload["projects"] = projects2
payload["count"] = len(projects2)
payload["refetched"] = True
payload["refetch_reason"] = "无法对应" if len(hits) != 1 else "unexpected"
if len(hits) == 0:
payload["refetch_reason"] = "首次0命中"
elif len(hits) > 1:
payload["refetch_reason"] = "首次多命中"
payload["matches"] = hits2
if len(hits2) == 1:
payload["match_status"] = "unique_after_refetch"
elif len(hits2) == 0:
payload["match_status"] = "none_after_refetch"
payload["note"] = "已自动重拉一次仍无法对应请用户从最新列表点选禁止再拉第3次"
else:
payload["match_status"] = "ambiguous_after_refetch"
payload["note"] = "已自动重拉一次仍多命中;请用户点选;禁止自动选定"
payload["fetched_at"] = datetime.now(TZ).isoformat()
return payload
def main() -> None:
ap = argparse.ArgumentParser(description="YunxiaoPMapp 项目列表 / 匹配(无法对应则重拉一次)")
ap.add_argument("--match", help="按项目名或 customCode 匹配")
ap.add_argument("--match-id", help="按 spaceIdentifier 匹配")
args = ap.parse_args()
print(
json.dumps(
list_projects(match=args.match, match_id=args.match_id),
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""标签候选列表(聚合工作项 tag + runtime未命中可 --match 后自动重拉一次。"""
from __future__ import annotations
import argparse
import json
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import requests
ROOT = Path(__file__).resolve().parents[1]
RUNTIME = json.loads((ROOT / "assets" / "runtime-ids.json").read_text())
TZ = timezone(timedelta(hours=8))
def load_jar() -> dict[str, str]:
jar: dict[str, str] = {}
try:
import 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
except Exception:
pass
if not jar.get("XSRF-TOKEN"):
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", [])}
)
if not jar.get("XSRF-TOKEN"):
raise RuntimeError("缺少 XSRF-TOKEN")
return jar
def session() -> requests.Session:
jar = load_jar()
x = jar.get("XSRF-TOKEN", "")
xsrf = urllib.parse.unquote(x) if "%" in x else x
s = requests.Session()
s.headers.update(
{
"Cookie": "; ".join(f"{k}={v}" for k, v in jar.items()),
"x-xsrf-token": xsrf,
"X-XSRF-TOKEN": xsrf,
"Origin": "https://devops.aliyun.com",
"Referer": "https://devops.aliyun.com/projex",
"accept": "application/json",
"Content-Type": "application/json",
}
)
return s
def fetch_tags(space_id: str) -> list[dict[str, str]]:
s = session()
by_id: dict[str, str] = {}
for name, tid in (RUNTIME.get("tags") or {}).items():
by_id[tid] = name
body = {
"spaceIdentifier": space_id,
"spaceType": "Project",
"category": "Req",
"toPage": 1,
"pageSize": 100,
"conditions": json.dumps({"conditionGroups": [[]]}),
}
rows = (
s.post(
"https://devops.aliyun.com/projex/api/workitem/workitem/list?_input_charset=utf-8",
json=body,
timeout=30,
)
.json()
.get("result")
or []
)
for row in rows:
for t in row.get("tag") or []:
tid = t.get("identifier")
name = t.get("name") or t.get("displayName")
if tid and name:
by_id[tid] = name
# Task 再扫一页,补标签
body["category"] = "Task"
rows = (
s.post(
"https://devops.aliyun.com/projex/api/workitem/workitem/list?_input_charset=utf-8",
json=body,
timeout=30,
)
.json()
.get("result")
or []
)
for row in rows:
for t in row.get("tag") or []:
tid = t.get("identifier")
name = t.get("name") or t.get("displayName")
if tid and name:
by_id[tid] = name
return [{"name": n, "identifier": i} for i, n in sorted(by_id.items(), key=lambda x: x[1])]
def match_tags(tags: list[dict[str, str]], query: str) -> list[dict[str, str]]:
q = query.strip().lower()
hits = []
for t in tags:
name = (t.get("name") or "").lower()
if q == name or q in name or name in q:
hits.append(t)
return hits
def list_tags(*, space_id: str, match: str | None = None, prefer: str | None = None) -> dict[str, Any]:
tags = fetch_tags(space_id)
# prefer 置顶
if prefer:
prefer_l = prefer.strip().lower()
tags = sorted(
tags,
key=lambda t: (0 if (t.get("name") or "").lower() == prefer_l else 1, t.get("name") or ""),
)
out: dict[str, Any] = {
"fetched_at": datetime.now(TZ).isoformat(),
"spaceIdentifier": space_id,
"count": len(tags),
"tags": tags,
"refetched": False,
"note": "标签未命中须自动重拉一次并重生 4.A/B/C 选项;见 compact-select.md",
}
if not match:
return out
hits = match_tags(tags, match)
if len(hits) == 1:
out["matches"] = hits
out["match_status"] = "unique"
return out
# 无法对应 → 重拉一次
tags2 = fetch_tags(space_id)
if prefer:
prefer_l = prefer.strip().lower()
tags2 = sorted(
tags2,
key=lambda t: (0 if (t.get("name") or "").lower() == prefer_l else 1, t.get("name") or ""),
)
hits2 = match_tags(tags2, match)
out["tags"] = tags2
out["count"] = len(tags2)
out["refetched"] = True
out["refetch_reason"] = "首次0命中" if len(hits) == 0 else "首次多命中"
out["matches"] = hits2
if len(hits2) == 1:
out["match_status"] = "unique_after_refetch"
elif len(hits2) == 0:
out["match_status"] = "none_after_refetch"
out["note"] = "已自动重拉标签列表仍无法对应;请用户点选 4.x禁止再拉第3次"
else:
out["match_status"] = "ambiguous_after_refetch"
out["fetched_at"] = datetime.now(TZ).isoformat()
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--space", required=True, help="spaceIdentifier")
ap.add_argument("--match", help="标签名")
ap.add_argument("--prefer", help="置顶标签名")
args = ap.parse_args()
print(
json.dumps(
list_tags(space_id=args.space, match=args.match, prefer=args.prefer),
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,660 @@
#!/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"<h2>{line[3:]}</h2>")
elif line.startswith("### "):
parts.append(f"<h3>{line[4:]}</h3>")
elif line.startswith("- "):
parts.append(f"<p>• {line[2:]}</p>")
elif line.strip():
parts.append(f"<p>{line}</p>")
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"<p>{PLACEHOLDER}</p>",
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}",
"<p>分析阶段:故障处置台账、挂起归档与证据链。</p>",
WANG,
associated_req=rid,
parent_delivery=did,
),
)
f_des = pool.submit(
create,
session(),
task_payload(
f"【设计】{title}",
f"<p>设计阶段:对齐原型 {PROTO}</p>",
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"<p>{PLACEHOLDER}</p>"
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()

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Compute YunxiaoPMapp stage calendar hours = workdays × 8.
Usage:
python3 workday_hours.py YYYY-MM-DD YYYY-MM-DD
python3 workday_hours.py YYYY-MM-DD YYYY-MM-DD --calendar /path/to/cn-workday-calendar.json
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
def parse_day(s: str) -> date:
return datetime.strptime(s, "%Y-%m-%d").date()
def load_calendar(path: Path) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
years = data.get("years") or {}
holidays: set[str] = set()
makeup: set[str] = set()
for _y, block in years.items():
holidays.update(block.get("holidays") or [])
makeup.update(block.get("workdays_on_weekend") or [])
return {"holidays": holidays, "makeup": makeup, "years": set(years.keys())}
def ensure_years_covered(start: date, end: date, years: set[str]) -> None:
y = start.year
while y <= end.year:
if str(y) not in years:
raise SystemExit(
f"缺少 {y} 年日历:请在 assets/cn-workday-calendar.json 补全后再计算。"
"禁止仅去周末静默估算。"
)
y += 1
def is_workday(d: date, holidays: set[str], makeup: set[str]) -> bool:
key = d.isoformat()
if key in holidays:
return False
if key in makeup:
return True
return d.weekday() < 5 # Mon=0 .. Fri=4
def count_workdays(start: date, end: date, holidays: set[str], makeup: set[str]) -> int:
if end < start:
raise SystemExit("结束日早于开始日")
n = 0
cur = start
while cur <= end:
if is_workday(cur, holidays, makeup):
n += 1
cur += timedelta(days=1)
return n
def main() -> None:
parser = argparse.ArgumentParser(description="阶段日历工时 = 工作日×8")
parser.add_argument("start", help="计划开始 YYYY-MM-DD")
parser.add_argument("end", help="计划完成 YYYY-MM-DD")
parser.add_argument(
"--calendar",
default=str(Path(__file__).resolve().parent.parent / "assets" / "cn-workday-calendar.json"),
help="日历 JSON 路径",
)
args = parser.parse_args()
start = parse_day(args.start)
end = parse_day(args.end)
cal = load_calendar(Path(args.calendar))
ensure_years_covered(start, end, cal["years"])
days = count_workdays(start, end, cal["holidays"], cal["makeup"])
hours = days * 8
out = {
"start": start.isoformat(),
"end": end.isoformat(),
"workdays": days,
"stage_calendar_hours": hours,
"label": "阶段日历工时工作日×8非人力投入预估",
}
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()