@hupan56/wlkj 3.3.1 → 3.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,208 +1,208 @@
1
- # -*- coding: utf-8 -*-
2
- """integration.spec_upload —— 开发 SPEC 制品回流平台(引擎侧客户端)。
3
-
4
- 背景: wlinkj-workspace/docs/ENGINE-SPEC-UPLOAD.md §4.2(wl-spec 第 7-9 步)
5
- 契约: wlinkj-workspace/docs/SPEC-MCP-CONTRACT.md v1(create/confirm/update/get_spec)
6
-
7
- 流程: 解析契约头 → 有platform_spec_id走update/无走create → confirm → 回写幂等锚
8
- 传输: POST /mcp direct JSON-RPC + X-MCP-Token(同 engine/poller.py:_mcp_call)
9
- """
10
- from __future__ import annotations
11
- import json, os, re, sys, urllib.request, urllib.error
12
- from typing import Optional, Tuple
13
-
14
- # 直接运行时把 .qoder/scripts 注入 sys.path(让 foundation.* 可 import)
15
- _SCRIPT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
- if _SCRIPT_ROOT not in sys.path:
17
- sys.path.insert(0, _SCRIPT_ROOT)
18
-
19
-
20
- def _load_mcp_config() -> dict:
21
- from pathlib import Path
22
- try:
23
- from foundation.core.paths import get_repo_root
24
- repo = get_repo_root()
25
- except Exception:
26
- repo = Path(__file__).resolve().parent.parent.parent.parent.parent
27
- for cand in [repo / "mcp_config.json",
28
- repo / ".qoder" / "mcp_config.json"]:
29
- try:
30
- if cand.is_file():
31
- with open(cand, encoding="utf-8") as f:
32
- return json.load(f)
33
- except Exception:
34
- continue
35
- return {}
36
-
37
-
38
- def _resolve_endpoint() -> Tuple[str, str, str]:
39
- """返回 (base, token, pid)。env 覆盖 > mcp_config.json。"""
40
- cfg = _load_mcp_config()
41
- base = os.environ.get("WLKJ_MCP_BASE")
42
- if not base:
43
- ep = cfg.get("return_endpoint") or {}
44
- url = ep.get("url", "")
45
- base = url.split("/api/")[0] if "/api/" in url else ""
46
- if not base:
47
- for srv in (cfg.get("mcpServers") or {}).values():
48
- u = srv.get("url", "")
49
- if "/mcp" in u:
50
- base = u.split("/mcp")[0]; break
51
- base = (base or os.environ.get("WLKJ_BASE_URL", "http://10.89.7.120")).rstrip("/")
52
- token = os.environ.get("WLKJ_MCP_TOKEN", "")
53
- if not token:
54
- for srv in (cfg.get("mcpServers") or {}).values():
55
- t = srv.get("token", "")
56
- if t and "WILL_BE_FILLED" not in t:
57
- token = t; break
58
- pid = os.environ.get("WLKJ_PROJECT_ID", "")
59
- if not pid:
60
- for srv in (cfg.get("mcpServers") or {}).values():
61
- env = srv.get("env") or {}
62
- p = env.get("WLKJ_PROJECT_ID", "")
63
- if p and "WILL_BE_FILLED" not in p:
64
- pid = p; break
65
- return base, token, pid
66
-
67
-
68
- def _mcp_call(base: str, token: str, tool: str, args: dict) -> dict:
69
- """POST /mcp JSON-RPC tools/call,解析 result.content[0].text。"""
70
- body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
71
- "params": {"name": tool, "arguments": args}}
72
- req = urllib.request.Request(base + "/mcp",
73
- data=json.dumps(body).encode("utf-8"),
74
- headers={"Content-Type": "application/json", "X-MCP-Token": token}, method="POST")
75
- try:
76
- with urllib.request.urlopen(req, timeout=60) as resp:
77
- r = json.loads(resp.read().decode("utf-8", "replace"))
78
- except urllib.error.HTTPError as e:
79
- try: return {"error": "HTTP %d: %s" % (e.code, e.read().decode("utf-8","replace")[:200])}
80
- except Exception: return {"error": "HTTP %d" % e.code}
81
- except Exception as e:
82
- return {"error": "网络异常: %s" % str(e)[:150]}
83
- if r.get("error"):
84
- return {"error": "JSON-RPC error: %s" % str(r["error"])[:200]}
85
- text = (r.get("result", {}).get("content") or [{}])[0].get("text", "")
86
- try:
87
- return json.loads(text) if text else {}
88
- except Exception:
89
- return {"error": "结果非 JSON: %s" % text[:200]}
90
-
91
-
92
- _CONTRACT_RE = re.compile(r"<!--\s*@contract\s+spec[^\n]*\n(?P<body>.*?)-->", re.DOTALL)
93
-
94
- def parse_spec_contract(md: str) -> dict:
95
- fields = {}
96
- m = _CONTRACT_RE.search(md)
97
- if m:
98
- for line in m.group("body").splitlines():
99
- if ":" in line:
100
- k, _, v = line.partition(":")
101
- fields[k.strip()] = v.strip()
102
- title = ""
103
- for line in md.splitlines():
104
- if line.startswith("# "):
105
- title = line[2:].strip(); break
106
- zentao_id = fields.get("zentao_id", "")
107
- if not zentao_id:
108
- mt = re.search(r"story\s*=\s*(\d+)", fields.get("zentao", ""))
109
- zentao_id = mt.group(1) if mt else ""
110
- return {"title": title, "zentao_id": zentao_id,
111
- "platform_spec_id": fields.get("platform_spec_id", ""),
112
- "source_prd_id": fields.get("source_prd_id") or fields.get("source-req", ""),
113
- "content_md": md}
114
-
115
- def write_back_platform_spec_id(md: str, spec_id: str) -> str:
116
- m = _CONTRACT_RE.search(md)
117
- if not m: return md
118
- body = m.group("body")
119
- if re.search(r"^\s*platform_spec_id\s*:", body, re.MULTILINE):
120
- new_body = re.sub(r"(\bplatform_spec_id[ \t]*:[ \t]*)[^\n]*", r"\g<1>%s" % spec_id, body)
121
- else:
122
- new_body = body.rstrip() + "\nplatform_spec_id: %s\n" % spec_id
123
- return md[:m.start("body")] + new_body + md[m.end("body"):]
124
-
125
-
126
- def upload_spec(spec_path: str, zentao_id_override: Optional[str] = None,
127
- code_targets: Optional[list] = None) -> dict:
128
- try:
129
- with open(spec_path, encoding="utf-8", errors="replace") as f:
130
- md = f.read()
131
- except Exception as e:
132
- return {"ok": False, "via": "none", "message": "读不到 spec: %s" % e}
133
-
134
- c = parse_spec_contract(md)
135
- title = c["title"] or os.path.splitext(os.path.basename(spec_path))[0]
136
- zentao_id = zentao_id_override or c["zentao_id"]
137
- if not zentao_id:
138
- return {"ok": False, "via": "none",
139
- "message": "缺 zentao_id(契约头无且未 --zentao-id)—— 取消(无关联键)"}
140
-
141
- base, token, pid = _resolve_endpoint()
142
- if not token:
143
- return {"ok": False, "via": "none",
144
- "message": "mcp_config.json 未绑定 token(先 /wl-init)或设 WLKJ_MCP_TOKEN"}
145
-
146
- code_targets = code_targets or []
147
- existing_id = c["platform_spec_id"]
148
-
149
- # 1) create 或 update(幂等)
150
- if existing_id:
151
- r = _mcp_call(base, token, "update_spec",
152
- {"spec_id": existing_id, "content_md": c["content_md"]})
153
- via, spec_id, version = "update_spec", existing_id, r.get("version")
154
- else:
155
- args = {"title": title, "zentao_id": zentao_id, "content_md": c["content_md"]}
156
- if pid: args["project_id"] = pid
157
- if c["source_prd_id"]: args["source_prd_id"] = c["source_prd_id"]
158
- r = _mcp_call(base, token, "create_spec", args)
159
- via, spec_id, version = "create_spec", r.get("id"), r.get("version")
160
-
161
- if r.get("error") or not spec_id:
162
- return {"ok": False, "via": via, "message": r.get("error", "无 spec_id 返回")}
163
-
164
- # 2) confirm(draft→confirmed)
165
- rc = _mcp_call(base, token, "confirm_spec",
166
- {"spec_id": spec_id, "code_targets": code_targets})
167
- status = rc.get("status", "")
168
-
169
- # 3) 回写幂等锚(仅 create 后)
170
- if not existing_id:
171
- try:
172
- new_md = write_back_platform_spec_id(md, spec_id)
173
- with open(spec_path, "w", encoding="utf-8") as f:
174
- f.write(new_md)
175
- except Exception:
176
- pass
177
-
178
- return {"ok": True, "platform_spec_id": spec_id, "via": via,
179
- "status": status or r.get("status", ""), "version": version,
180
- "requirement_id": r.get("requirement_id"),
181
- "created_new": r.get("created_new"),
182
- "message": "上传成功:%s(zentao_id=%s → requirement_id=%s)" % (
183
- title, zentao_id, r.get("requirement_id"))}
184
-
185
-
186
- def cli_main() -> int:
187
- args = sys.argv[1:]
188
- if not args or args[0] in ("-h", "--help"):
189
- print("用法: python -m domain.integration.spec_upload <spec.md> "
190
- "[--zentao-id N] [--code-targets A,B]"); return 1
191
- spec_path = args[0]
192
- zentao_id = None; code_targets = []
193
- if "--zentao-id" in args:
194
- i = args.index("--zentao-id")
195
- zentao_id = args[i+1] if i+1 < len(args) else None
196
- if "--code-targets" in args:
197
- i = args.index("--code-targets")
198
- code_targets = [x.strip() for x in args[i+1].split(",")] if i+1 < len(args) else []
199
- r = upload_spec(spec_path, zentao_id_override=zentao_id, code_targets=code_targets)
200
- print("%s [spec-upload] via=%s %s" % ("✅" if r["ok"] else "⚠️", r["via"], r["message"]))
201
- if r["ok"]:
202
- print(" platform_spec_id=%s status=%s version=%s" % (
203
- r["platform_spec_id"], r["status"], r["version"]))
204
- return 0 # 上传失败返回0:本地spec已落地,上传是增强不阻塞
205
-
206
-
207
- if __name__ == "__main__":
208
- raise SystemExit(cli_main())
1
+ # -*- coding: utf-8 -*-
2
+ """integration.spec_upload —— 开发 SPEC 制品回流平台(引擎侧客户端)。
3
+
4
+ 背景: wlinkj-workspace/docs/ENGINE-SPEC-UPLOAD.md §4.2(wl-spec 第 7-9 步)
5
+ 契约: wlinkj-workspace/docs/SPEC-MCP-CONTRACT.md v1(create/confirm/update/get_spec)
6
+
7
+ 流程: 解析契约头 → 有platform_spec_id走update/无走create → confirm → 回写幂等锚
8
+ 传输: POST /mcp direct JSON-RPC + X-MCP-Token(同 engine/poller.py:_mcp_call)
9
+ """
10
+ from __future__ import annotations
11
+ import json, os, re, sys, urllib.request, urllib.error
12
+ from typing import Optional, Tuple
13
+
14
+ # 直接运行时把 .qoder/scripts 注入 sys.path(让 foundation.* 可 import)
15
+ _SCRIPT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+ if _SCRIPT_ROOT not in sys.path:
17
+ sys.path.insert(0, _SCRIPT_ROOT)
18
+
19
+
20
+ def _load_mcp_config() -> dict:
21
+ from pathlib import Path
22
+ try:
23
+ from foundation.core.paths import get_repo_root
24
+ repo = get_repo_root()
25
+ except Exception:
26
+ repo = Path(__file__).resolve().parent.parent.parent.parent.parent
27
+ for cand in [repo / "mcp_config.json",
28
+ repo / ".qoder" / "mcp_config.json"]:
29
+ try:
30
+ if cand.is_file():
31
+ with open(cand, encoding="utf-8") as f:
32
+ return json.load(f)
33
+ except Exception:
34
+ continue
35
+ return {}
36
+
37
+
38
+ def _resolve_endpoint() -> Tuple[str, str, str]:
39
+ """返回 (base, token, pid)。env 覆盖 > mcp_config.json。"""
40
+ cfg = _load_mcp_config()
41
+ base = os.environ.get("WLKJ_MCP_BASE")
42
+ if not base:
43
+ ep = cfg.get("return_endpoint") or {}
44
+ url = ep.get("url", "")
45
+ base = url.split("/api/")[0] if "/api/" in url else ""
46
+ if not base:
47
+ for srv in (cfg.get("mcpServers") or {}).values():
48
+ u = srv.get("url", "")
49
+ if "/mcp" in u:
50
+ base = u.split("/mcp")[0]; break
51
+ base = (base or "http://127.0.0.1:10010").rstrip("/")
52
+ token = os.environ.get("WLKJ_MCP_TOKEN", "")
53
+ if not token:
54
+ for srv in (cfg.get("mcpServers") or {}).values():
55
+ t = srv.get("token", "")
56
+ if t and "WILL_BE_FILLED" not in t:
57
+ token = t; break
58
+ pid = os.environ.get("WLKJ_PROJECT_ID", "")
59
+ if not pid:
60
+ for srv in (cfg.get("mcpServers") or {}).values():
61
+ env = srv.get("env") or {}
62
+ p = env.get("WLKJ_PROJECT_ID", "")
63
+ if p and "WILL_BE_FILLED" not in p:
64
+ pid = p; break
65
+ return base, token, pid
66
+
67
+
68
+ def _mcp_call(base: str, token: str, tool: str, args: dict) -> dict:
69
+ """POST /mcp JSON-RPC tools/call,解析 result.content[0].text。"""
70
+ body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
71
+ "params": {"name": tool, "arguments": args}}
72
+ req = urllib.request.Request(base + "/mcp",
73
+ data=json.dumps(body).encode("utf-8"),
74
+ headers={"Content-Type": "application/json", "X-MCP-Token": token}, method="POST")
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=60) as resp:
77
+ r = json.loads(resp.read().decode("utf-8", "replace"))
78
+ except urllib.error.HTTPError as e:
79
+ try: return {"error": "HTTP %d: %s" % (e.code, e.read().decode("utf-8","replace")[:200])}
80
+ except Exception: return {"error": "HTTP %d" % e.code}
81
+ except Exception as e:
82
+ return {"error": "网络异常: %s" % str(e)[:150]}
83
+ if r.get("error"):
84
+ return {"error": "JSON-RPC error: %s" % str(r["error"])[:200]}
85
+ text = (r.get("result", {}).get("content") or [{}])[0].get("text", "")
86
+ try:
87
+ return json.loads(text) if text else {}
88
+ except Exception:
89
+ return {"error": "结果非 JSON: %s" % text[:200]}
90
+
91
+
92
+ _CONTRACT_RE = re.compile(r"<!--\s*@contract\s+spec[^\n]*\n(?P<body>.*?)-->", re.DOTALL)
93
+
94
+ def parse_spec_contract(md: str) -> dict:
95
+ fields = {}
96
+ m = _CONTRACT_RE.search(md)
97
+ if m:
98
+ for line in m.group("body").splitlines():
99
+ if ":" in line:
100
+ k, _, v = line.partition(":")
101
+ fields[k.strip()] = v.strip()
102
+ title = ""
103
+ for line in md.splitlines():
104
+ if line.startswith("# "):
105
+ title = line[2:].strip(); break
106
+ zentao_id = fields.get("zentao_id", "")
107
+ if not zentao_id:
108
+ mt = re.search(r"story\s*=\s*(\d+)", fields.get("zentao", ""))
109
+ zentao_id = mt.group(1) if mt else ""
110
+ return {"title": title, "zentao_id": zentao_id,
111
+ "platform_spec_id": fields.get("platform_spec_id", ""),
112
+ "source_prd_id": fields.get("source_prd_id") or fields.get("source-req", ""),
113
+ "content_md": md}
114
+
115
+ def write_back_platform_spec_id(md: str, spec_id: str) -> str:
116
+ m = _CONTRACT_RE.search(md)
117
+ if not m: return md
118
+ body = m.group("body")
119
+ if re.search(r"^\s*platform_spec_id\s*:", body, re.MULTILINE):
120
+ new_body = re.sub(r"(\bplatform_spec_id[ \t]*:[ \t]*)[^\n]*", r"\g<1>%s" % spec_id, body)
121
+ else:
122
+ new_body = body.rstrip() + "\nplatform_spec_id: %s\n" % spec_id
123
+ return md[:m.start("body")] + new_body + md[m.end("body"):]
124
+
125
+
126
+ def upload_spec(spec_path: str, zentao_id_override: Optional[str] = None,
127
+ code_targets: Optional[list] = None) -> dict:
128
+ try:
129
+ with open(spec_path, encoding="utf-8", errors="replace") as f:
130
+ md = f.read()
131
+ except Exception as e:
132
+ return {"ok": False, "via": "none", "message": "读不到 spec: %s" % e}
133
+
134
+ c = parse_spec_contract(md)
135
+ title = c["title"] or os.path.splitext(os.path.basename(spec_path))[0]
136
+ zentao_id = zentao_id_override or c["zentao_id"]
137
+ if not zentao_id:
138
+ return {"ok": False, "via": "none",
139
+ "message": "缺 zentao_id(契约头无且未 --zentao-id)—— 取消(无关联键)"}
140
+
141
+ base, token, pid = _resolve_endpoint()
142
+ if not token:
143
+ return {"ok": False, "via": "none",
144
+ "message": "mcp_config.json 未绑定 token(先 /wl-init)或设 WLKJ_MCP_TOKEN"}
145
+
146
+ code_targets = code_targets or []
147
+ existing_id = c["platform_spec_id"]
148
+
149
+ # 1) create 或 update(幂等)
150
+ if existing_id:
151
+ r = _mcp_call(base, token, "update_spec",
152
+ {"spec_id": existing_id, "content_md": c["content_md"]})
153
+ via, spec_id, version = "update_spec", existing_id, r.get("version")
154
+ else:
155
+ args = {"title": title, "zentao_id": zentao_id, "content_md": c["content_md"]}
156
+ if pid: args["project_id"] = pid
157
+ if c["source_prd_id"]: args["source_prd_id"] = c["source_prd_id"]
158
+ r = _mcp_call(base, token, "create_spec", args)
159
+ via, spec_id, version = "create_spec", r.get("id"), r.get("version")
160
+
161
+ if r.get("error") or not spec_id:
162
+ return {"ok": False, "via": via, "message": r.get("error", "无 spec_id 返回")}
163
+
164
+ # 2) confirm(draft→confirmed)
165
+ rc = _mcp_call(base, token, "confirm_spec",
166
+ {"spec_id": spec_id, "code_targets": code_targets})
167
+ status = rc.get("status", "")
168
+
169
+ # 3) 回写幂等锚(仅 create 后)
170
+ if not existing_id:
171
+ try:
172
+ new_md = write_back_platform_spec_id(md, spec_id)
173
+ with open(spec_path, "w", encoding="utf-8") as f:
174
+ f.write(new_md)
175
+ except Exception:
176
+ pass
177
+
178
+ return {"ok": True, "platform_spec_id": spec_id, "via": via,
179
+ "status": status or r.get("status", ""), "version": version,
180
+ "requirement_id": r.get("requirement_id"),
181
+ "created_new": r.get("created_new"),
182
+ "message": "上传成功:%s(zentao_id=%s → requirement_id=%s)" % (
183
+ title, zentao_id, r.get("requirement_id"))}
184
+
185
+
186
+ def cli_main() -> int:
187
+ args = sys.argv[1:]
188
+ if not args or args[0] in ("-h", "--help"):
189
+ print("用法: python -m domain.integration.spec_upload <spec.md> "
190
+ "[--zentao-id N] [--code-targets A,B]"); return 1
191
+ spec_path = args[0]
192
+ zentao_id = None; code_targets = []
193
+ if "--zentao-id" in args:
194
+ i = args.index("--zentao-id")
195
+ zentao_id = args[i+1] if i+1 < len(args) else None
196
+ if "--code-targets" in args:
197
+ i = args.index("--code-targets")
198
+ code_targets = [x.strip() for x in args[i+1].split(",")] if i+1 < len(args) else []
199
+ r = upload_spec(spec_path, zentao_id_override=zentao_id, code_targets=code_targets)
200
+ print("%s [spec-upload] via=%s %s" % ("✅" if r["ok"] else "⚠️", r["via"], r["message"]))
201
+ if r["ok"]:
202
+ print(" platform_spec_id=%s status=%s version=%s" % (
203
+ r["platform_spec_id"], r["status"], r["version"]))
204
+ return 0 # 上传失败返回0:本地spec已落地,上传是增强不阻塞
205
+
206
+
207
+ if __name__ == "__main__":
208
+ raise SystemExit(cli_main())