oh-aicoding-tool 0.1.0
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.
- package/CODEX_LANGFUSE_PLAN.md +62 -0
- package/README.md +114 -0
- package/bin/cli.js +166 -0
- package/bin/langfuse-cli.js +718 -0
- package/codex_langfuse_notify.py +591 -0
- package/langfuse_hook.py +603 -0
- package/opencode-ohai-report/.claude/commands/report-ai-issue.md +60 -0
- package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +30 -0
- package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +569 -0
- package/opencode-ohai-report/README.md +45 -0
- package/opencode-ohai-report/bin/cli.js +421 -0
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +313 -0
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +476 -0
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +405 -0
- package/opencode-ohai-report/examples/issue_output.json +4 -0
- package/opencode-ohai-report/package.json +40 -0
- package/opencode-ohai-report/scripts/claude_report_hook.py +257 -0
- package/opencode-ohai-report/scripts/create_issue.py +34 -0
- package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +254 -0
- package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +264 -0
- package/opencode-ohai-report/scripts/install-opencode-plugin.sh +218 -0
- package/opencode-ohai-report/scripts/merge-claude-settings.py +99 -0
- package/opencode-ohai-report/tools/ohai-report/README.md +151 -0
- package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +26 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +5 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +9 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +319 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +32 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +14 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +313 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +360 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +1 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +38 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +64 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +80 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +1 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +15 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +405 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +21 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +354 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +9 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +61 -0
- package/opencode-ohai-report/tools/ohai-report/ohai_report.py +10 -0
- package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +166 -0
- package/package.json +59 -0
- package/scripts/codex-langfuse-check.mjs +101 -0
- package/scripts/codex-langfuse-setup.mjs +181 -0
- package/scripts/langfuse-check.mjs +90 -0
- package/scripts/langfuse-setup.mjs +278 -0
- package/scripts/opencode-langfuse-check.mjs +94 -0
- package/scripts/opencode-langfuse-run.mjs +96 -0
- package/scripts/opencode-langfuse-setup.mjs +478 -0
- package/scripts/resolve-opencode-cli.mjs +58 -0
- package/setup-langfuse.bat +163 -0
- package/setup-langfuse.sh +130 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""HTTP JSON webhook sink: POST JSON(title、body;非空时含 labels 逗号分隔字符串)供外部建单服务消费。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..issue_markdown import derive_gitcode_dimension_labels, format_issue_markdown, sanitize_gitcode_label_value
|
|
15
|
+
from ..payload import generate_issue_id
|
|
16
|
+
from ..webhook_defaults import DEFAULT_WEBHOOK_URL
|
|
17
|
+
from .base import SinkResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_labels_value(value: Any) -> list[str]:
|
|
21
|
+
"""将 issue 字段或 CLI 中的 labels 规范为去重后的非空字符串列表。"""
|
|
22
|
+
if value is None:
|
|
23
|
+
return []
|
|
24
|
+
if isinstance(value, list):
|
|
25
|
+
out: list[str] = []
|
|
26
|
+
for x in value:
|
|
27
|
+
if x is None:
|
|
28
|
+
continue
|
|
29
|
+
t = str(x).strip()
|
|
30
|
+
if t:
|
|
31
|
+
out.append(t)
|
|
32
|
+
return out
|
|
33
|
+
s = str(value).strip()
|
|
34
|
+
if not s:
|
|
35
|
+
return []
|
|
36
|
+
return [p.strip() for p in s.split(",") if p.strip()]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_webhook_post_labels(issue: dict[str, Any], cli_labels: str | None) -> list[str]:
|
|
40
|
+
"""决定 POST 体中的 labels:显式传入 ``--webhook-labels`` 时仅使用该串(与 GitCode CLI 覆盖 env 一致);否则合并 issue.labels 与 ``OHAI_WEBHOOK_LABELS``。"""
|
|
41
|
+
if cli_labels is not None:
|
|
42
|
+
return parse_labels_value(cli_labels)
|
|
43
|
+
merged: list[str] = []
|
|
44
|
+
merged.extend(parse_labels_value(issue.get("labels")))
|
|
45
|
+
merged.extend(parse_labels_value(os.environ.get("OHAI_WEBHOOK_LABELS", "")))
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
for p in merged:
|
|
49
|
+
if p not in seen:
|
|
50
|
+
seen.add(p)
|
|
51
|
+
out.append(p)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def merge_webhook_post_label_list(
|
|
56
|
+
payload: dict[str, Any],
|
|
57
|
+
issue: dict[str, Any],
|
|
58
|
+
cli_labels: str | None,
|
|
59
|
+
) -> list[str]:
|
|
60
|
+
"""维度标签(与 gitcode sink 一致)在前,其次 ``resolve_webhook_post_labels``;大小写不敏感去重。"""
|
|
61
|
+
dim = derive_gitcode_dimension_labels(payload)
|
|
62
|
+
rest = resolve_webhook_post_labels(issue, cli_labels)
|
|
63
|
+
seen: set[str] = set()
|
|
64
|
+
merged: list[str] = []
|
|
65
|
+
for group in (dim, rest):
|
|
66
|
+
for name in group:
|
|
67
|
+
t = sanitize_gitcode_label_value(name)
|
|
68
|
+
if not t:
|
|
69
|
+
continue
|
|
70
|
+
k = t.casefold()
|
|
71
|
+
if k in seen:
|
|
72
|
+
continue
|
|
73
|
+
seen.add(k)
|
|
74
|
+
merged.append(t)
|
|
75
|
+
return merged
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_webhook_labels_string(labels: list[str]) -> str:
|
|
79
|
+
"""接收端常见约定:``\"bug,critical\"`` JSON 字符串,而非数组。"""
|
|
80
|
+
return ",".join(str(x).strip() for x in labels if str(x).strip())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_webhook_labels_color_string(labels: list[str]) -> str:
|
|
84
|
+
"""将标签列表一一映射为颜色 HEX 字符串,再以逗号分隔。"""
|
|
85
|
+
from .gitcode import gitcode_label_color
|
|
86
|
+
return ",".join(gitcode_label_color(str(x).strip()) for x in labels if str(x).strip())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WebhookSinkError(RuntimeError):
|
|
90
|
+
def __init__(self, message: str, *, status_code: int = 0, body: str = "") -> None:
|
|
91
|
+
super().__init__(message)
|
|
92
|
+
self.status_code = status_code
|
|
93
|
+
self.body = body
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _url_from_mapping(m: dict[str, Any]) -> str:
|
|
97
|
+
for key in (
|
|
98
|
+
"html_url",
|
|
99
|
+
"url",
|
|
100
|
+
"issue_url",
|
|
101
|
+
"link",
|
|
102
|
+
"web_url",
|
|
103
|
+
"ticket_url",
|
|
104
|
+
"browser_url",
|
|
105
|
+
"permalink",
|
|
106
|
+
"detail_url",
|
|
107
|
+
"issueUrl",
|
|
108
|
+
"issue_link",
|
|
109
|
+
):
|
|
110
|
+
v = m.get(key)
|
|
111
|
+
if isinstance(v, str) and v.strip().startswith(("http://", "https://")):
|
|
112
|
+
return v.strip()
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_remote_url(raw: str) -> str:
|
|
117
|
+
raw = (raw or "").strip()
|
|
118
|
+
if not raw:
|
|
119
|
+
return ""
|
|
120
|
+
first_line = raw.splitlines()[0].strip()
|
|
121
|
+
if first_line.startswith(("http://", "https://")):
|
|
122
|
+
return first_line
|
|
123
|
+
try:
|
|
124
|
+
obj = json.loads(raw)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
return ""
|
|
127
|
+
if isinstance(obj, list) and obj:
|
|
128
|
+
first = obj[0]
|
|
129
|
+
if isinstance(first, str) and first.strip().startswith(("http://", "https://")):
|
|
130
|
+
return first.strip()
|
|
131
|
+
if isinstance(first, dict):
|
|
132
|
+
u = _url_from_mapping(first)
|
|
133
|
+
if u:
|
|
134
|
+
return u
|
|
135
|
+
if not isinstance(obj, dict):
|
|
136
|
+
return ""
|
|
137
|
+
u = _url_from_mapping(obj)
|
|
138
|
+
if u:
|
|
139
|
+
return u
|
|
140
|
+
for nest_key in ("data", "result", "payload", "body", "issue", "ticket", "item"):
|
|
141
|
+
nested = obj.get(nest_key)
|
|
142
|
+
if isinstance(nested, dict):
|
|
143
|
+
u = _url_from_mapping(nested)
|
|
144
|
+
if u:
|
|
145
|
+
return u
|
|
146
|
+
return ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_extra_ids(obj: dict[str, Any]) -> dict[str, Any]:
|
|
150
|
+
out: dict[str, Any] = {}
|
|
151
|
+
for key in ("number", "id", "iid", "issue_id", "key", "seq", "ticket_id"):
|
|
152
|
+
if key in obj and obj[key] is not None:
|
|
153
|
+
out[key] = obj[key]
|
|
154
|
+
for nest_key in ("data", "result", "issue", "ticket", "payload"):
|
|
155
|
+
data = obj.get(nest_key)
|
|
156
|
+
if isinstance(data, dict):
|
|
157
|
+
for key in ("number", "id", "iid", "issue_id", "key", "seq"):
|
|
158
|
+
if key in data and data[key] is not None:
|
|
159
|
+
out.setdefault(key, data[key])
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class WebhookConfig:
|
|
165
|
+
url: str
|
|
166
|
+
secret: str = ""
|
|
167
|
+
timeout: float = 45.0
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_env(
|
|
171
|
+
cls,
|
|
172
|
+
*,
|
|
173
|
+
url: str | None = None,
|
|
174
|
+
secret: str | None = None,
|
|
175
|
+
timeout: float | None = None,
|
|
176
|
+
) -> WebhookConfig:
|
|
177
|
+
env = os.environ
|
|
178
|
+
u = (url or "").strip()
|
|
179
|
+
if not u:
|
|
180
|
+
u = (env.get("OHAI_WEBHOOK_URL") or env.get("WEBHOOK_URL") or "").strip()
|
|
181
|
+
if not u:
|
|
182
|
+
u = DEFAULT_WEBHOOK_URL.strip()
|
|
183
|
+
if secret is not None:
|
|
184
|
+
sec = str(secret).strip()
|
|
185
|
+
else:
|
|
186
|
+
sec = (env.get("OHAI_WEBHOOK_SECRET") or env.get("WEBHOOK_SECRET") or "").strip()
|
|
187
|
+
if timeout is not None:
|
|
188
|
+
to = float(timeout)
|
|
189
|
+
else:
|
|
190
|
+
raw = (env.get("OHAI_WEBHOOK_TIMEOUT") or env.get("WEBHOOK_TIMEOUT") or "").strip()
|
|
191
|
+
if raw:
|
|
192
|
+
try:
|
|
193
|
+
to = float(raw)
|
|
194
|
+
except ValueError:
|
|
195
|
+
to = 45.0
|
|
196
|
+
else:
|
|
197
|
+
to = 45.0
|
|
198
|
+
return cls(url=u.strip(), secret=sec, timeout=float(to))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class WebhookIssueSink:
|
|
202
|
+
def __init__(self, config: WebhookConfig) -> None:
|
|
203
|
+
self.config = config
|
|
204
|
+
|
|
205
|
+
def post_title_body(self, title: str, body: str, labels: list[str] | None = None, labels_color: list[str] | None = None) -> SinkResult:
|
|
206
|
+
title = str(title or "").strip()
|
|
207
|
+
body = str(body or "").strip()
|
|
208
|
+
if not title:
|
|
209
|
+
raise WebhookSinkError("title 不能为空")
|
|
210
|
+
if not body:
|
|
211
|
+
raise WebhookSinkError("body 不能为空")
|
|
212
|
+
|
|
213
|
+
body_obj: dict[str, Any] = {"title": title, "body": body}
|
|
214
|
+
if labels:
|
|
215
|
+
body_obj["labels"] = format_webhook_labels_string(labels)
|
|
216
|
+
if labels_color:
|
|
217
|
+
body_obj["labels_color"] = format_webhook_labels_color_string(labels_color)
|
|
218
|
+
payload = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
|
|
219
|
+
headers: dict[str, str] = {
|
|
220
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
221
|
+
"Accept": "application/json",
|
|
222
|
+
}
|
|
223
|
+
if self.config.secret:
|
|
224
|
+
headers["X-Webhook-Secret"] = self.config.secret
|
|
225
|
+
|
|
226
|
+
req = urllib.request.Request(
|
|
227
|
+
self.config.url,
|
|
228
|
+
data=payload,
|
|
229
|
+
headers=headers,
|
|
230
|
+
method="POST",
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
|
|
234
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
235
|
+
code = int(getattr(resp, "status", 200) or 200)
|
|
236
|
+
except urllib.error.HTTPError as exc:
|
|
237
|
+
err_body = ""
|
|
238
|
+
try:
|
|
239
|
+
err_body = exc.read().decode("utf-8", errors="replace")
|
|
240
|
+
except OSError:
|
|
241
|
+
pass
|
|
242
|
+
raise WebhookSinkError(
|
|
243
|
+
f"Webhook 请求失败:HTTP {exc.code}",
|
|
244
|
+
status_code=int(exc.code or 0),
|
|
245
|
+
body=err_body,
|
|
246
|
+
) from exc
|
|
247
|
+
except OSError as exc:
|
|
248
|
+
raise WebhookSinkError(f"Webhook 请求失败:{exc}") from exc
|
|
249
|
+
|
|
250
|
+
if code >= 400:
|
|
251
|
+
raise WebhookSinkError(f"Webhook 返回异常状态:HTTP {code}", status_code=code, body=raw[:4096])
|
|
252
|
+
|
|
253
|
+
remote = _parse_remote_url(raw)
|
|
254
|
+
extra: dict[str, Any] = {"raw_response": raw[:8192]}
|
|
255
|
+
try:
|
|
256
|
+
obj = json.loads(raw)
|
|
257
|
+
if isinstance(obj, dict):
|
|
258
|
+
extra.update(_extract_extra_ids(obj))
|
|
259
|
+
except json.JSONDecodeError:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
return SinkResult(issue_id="", path=None, url=remote, extra=extra)
|
|
263
|
+
|
|
264
|
+
def save(self, payload: dict[str, Any], *, webhook_labels_cli: str | None = None) -> SinkResult:
|
|
265
|
+
issue = payload.get("issue") if isinstance(payload.get("issue"), dict) else {}
|
|
266
|
+
title = str(issue.get("title") or "").strip()
|
|
267
|
+
if not title:
|
|
268
|
+
raise WebhookSinkError("issue.title 不能为空")
|
|
269
|
+
body = format_issue_markdown(payload)
|
|
270
|
+
labels = merge_webhook_post_label_list(payload, issue, webhook_labels_cli)
|
|
271
|
+
labels_color = labels if labels else None
|
|
272
|
+
result = self.post_title_body(title, body, labels, labels_color)
|
|
273
|
+
iid = str(payload.get("issue_id") or "")
|
|
274
|
+
return SinkResult(issue_id=iid, path=result.path, url=result.url, extra=result.extra)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def apply_webhook_result(payload: dict[str, Any], result: SinkResult) -> dict[str, Any]:
|
|
278
|
+
out = dict(payload)
|
|
279
|
+
block: dict[str, Any] = {}
|
|
280
|
+
if result.url:
|
|
281
|
+
block["url"] = result.url
|
|
282
|
+
extra = getattr(result, "extra", {}) or {}
|
|
283
|
+
raw = extra.get("raw_response")
|
|
284
|
+
if isinstance(raw, str) and raw:
|
|
285
|
+
block["response_preview"] = raw[:2048]
|
|
286
|
+
for key in ("number", "id", "iid", "issue_id"):
|
|
287
|
+
if key in extra:
|
|
288
|
+
block[key] = extra[key]
|
|
289
|
+
out["webhook"] = block
|
|
290
|
+
out["status"] = "created-webhook"
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def push_legacy_issue_json_file(path: pathlib.Path) -> int:
|
|
295
|
+
"""Load {title, body|content} JSON and POST to webhook(与 scripts/create_issue.py 配套)。
|
|
296
|
+
|
|
297
|
+
成功时打印**一行 JSON**(与 ``ohai_report.py create --sink webhook --json`` 对齐),含 ``issue_id``(本地生成)
|
|
298
|
+
与 ``webhook_url``(从远端响应解析,可能为空),便于 agent / 脚本解析「已上报」链接。
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
with path.open("r", encoding="utf-8") as fp:
|
|
302
|
+
data = json.load(fp)
|
|
303
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
304
|
+
print(f"Error: cannot read JSON: {exc}")
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
if not isinstance(data, dict):
|
|
308
|
+
print("Error: JSON root must be an object")
|
|
309
|
+
return 1
|
|
310
|
+
|
|
311
|
+
title = str(data.get("title", "")).strip()
|
|
312
|
+
body = str(data.get("body") or data.get("content", "")).strip()
|
|
313
|
+
if not title or not body:
|
|
314
|
+
print("Error: Missing title or body/content in issue file")
|
|
315
|
+
return 1
|
|
316
|
+
|
|
317
|
+
pseudo_payload: dict[str, Any] = {
|
|
318
|
+
"source": str(data.get("source") or "opencode"),
|
|
319
|
+
"issue": {k: v for k, v in data.items() if k not in ("title", "body", "content")},
|
|
320
|
+
}
|
|
321
|
+
issue = pseudo_payload["issue"] if isinstance(pseudo_payload.get("issue"), dict) else {}
|
|
322
|
+
labels = merge_webhook_post_label_list(pseudo_payload, issue, None)
|
|
323
|
+
|
|
324
|
+
cfg = WebhookConfig.from_env()
|
|
325
|
+
try:
|
|
326
|
+
sink = WebhookIssueSink(cfg)
|
|
327
|
+
result = sink.post_title_body(title, body, labels)
|
|
328
|
+
except WebhookSinkError as exc:
|
|
329
|
+
err = f"Error {exc.status_code}: {exc.body}" if exc.status_code and exc.body else str(exc)
|
|
330
|
+
print(err)
|
|
331
|
+
return 1
|
|
332
|
+
|
|
333
|
+
now = dt.datetime.now(dt.timezone.utc).astimezone()
|
|
334
|
+
iid = generate_issue_id(now)
|
|
335
|
+
out: dict[str, Any] = {
|
|
336
|
+
"ok": True,
|
|
337
|
+
"issue_id": iid,
|
|
338
|
+
"webhook_url": (result.url or "").strip(),
|
|
339
|
+
"sink": "webhook",
|
|
340
|
+
"path": "",
|
|
341
|
+
"session_id": "",
|
|
342
|
+
"trace_id": "",
|
|
343
|
+
"observation_id": "",
|
|
344
|
+
"user_id": "",
|
|
345
|
+
"message_id": "",
|
|
346
|
+
"opencode_subagent": "",
|
|
347
|
+
"langfuse_url": "",
|
|
348
|
+
}
|
|
349
|
+
extra = getattr(result, "extra", None) or {}
|
|
350
|
+
for k in ("number", "id", "iid", "issue_id"):
|
|
351
|
+
if k in extra:
|
|
352
|
+
out[f"webhook_{k}"] = extra[k]
|
|
353
|
+
print(json.dumps(out, ensure_ascii=False))
|
|
354
|
+
return 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Workspace and filesystem helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def workspace_root() -> pathlib.Path:
|
|
12
|
+
return pathlib.Path.cwd()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_workspace_root(cwd_arg: str) -> pathlib.Path:
|
|
16
|
+
"""仓库根:优先 ``--cwd``,其次 ``OHAI_REPORT_CWD``,否则为进程当前目录。"""
|
|
17
|
+
t = (cwd_arg or "").strip()
|
|
18
|
+
if t:
|
|
19
|
+
return pathlib.Path(t).expanduser().resolve()
|
|
20
|
+
env_cwd = (os.environ.get("OHAI_REPORT_CWD") or "").strip()
|
|
21
|
+
if env_cwd:
|
|
22
|
+
return pathlib.Path(env_cwd).expanduser().resolve()
|
|
23
|
+
return workspace_root()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def data_dir(root: pathlib.Path) -> pathlib.Path:
|
|
27
|
+
path = root / ".ohai-report"
|
|
28
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
(path / "issues").mkdir(parents=True, exist_ok=True)
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_json(path: pathlib.Path) -> dict[str, Any]:
|
|
34
|
+
try:
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return {}
|
|
37
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
return {}
|
|
40
|
+
return value if isinstance(value, dict) else {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def strip_surrogates(value: Any) -> Any:
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
return value.encode("utf-8", errors="replace").decode("utf-8")
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [strip_surrogates(item) for item in value]
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
return {
|
|
50
|
+
strip_surrogates(key) if isinstance(key, str) else key: strip_surrogates(item)
|
|
51
|
+
for key, item in value.items()
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_json(path: pathlib.Path, value: dict[str, Any]) -> None:
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path.write_text(
|
|
59
|
+
json.dumps(strip_surrogates(value), ensure_ascii=False, indent=2) + "\n",
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.4.0",
|
|
3
|
+
"description": "Issue template fields for AI development issue reports(含「AI 使用问题反馈」扩展字段,均为可选除必填项外).",
|
|
4
|
+
"fields": {
|
|
5
|
+
"title": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"required": true,
|
|
8
|
+
"maxLength": 80,
|
|
9
|
+
"description": "标题,简短概括"
|
|
10
|
+
},
|
|
11
|
+
"category": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"required": true,
|
|
14
|
+
"enum": [
|
|
15
|
+
"模型输出错误",
|
|
16
|
+
"工具调用失败",
|
|
17
|
+
"Skill 缺陷",
|
|
18
|
+
"上下文缺失",
|
|
19
|
+
"环境问题",
|
|
20
|
+
"其他"
|
|
21
|
+
],
|
|
22
|
+
"description": "采集侧分类(枚举),与主分类可并存"
|
|
23
|
+
},
|
|
24
|
+
"summary": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"required": true,
|
|
27
|
+
"maxLength": 800,
|
|
28
|
+
"description": "问题摘要,可较完整描述现象与上下文"
|
|
29
|
+
},
|
|
30
|
+
"expected_behavior": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"required": true,
|
|
33
|
+
"maxLength": 400,
|
|
34
|
+
"description": "期望行为 / 可作为改进方向兜底"
|
|
35
|
+
},
|
|
36
|
+
"actual_behavior": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"required": true,
|
|
39
|
+
"maxLength": 400,
|
|
40
|
+
"description": "实际行为 / 可与影响描述呼应"
|
|
41
|
+
},
|
|
42
|
+
"severity": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"required": true,
|
|
45
|
+
"enum": ["P0", "P1", "P2", "P3"],
|
|
46
|
+
"default": "P2",
|
|
47
|
+
"description": "Issue severity"
|
|
48
|
+
},
|
|
49
|
+
"user_description": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"required": true,
|
|
52
|
+
"maxLength": 2000,
|
|
53
|
+
"description": "原始用户描述"
|
|
54
|
+
},
|
|
55
|
+
"agent": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"enum": ["opencode", "claude", "codex"],
|
|
58
|
+
"description": "使用的 Agent 工具(标签 agent:),当前仅 opencode / claude / codex"
|
|
59
|
+
},
|
|
60
|
+
"model": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"maxLength": 128,
|
|
63
|
+
"description": "使用的模型(标签 model:),如 deepseek、minimax、gpt-5.5 等"
|
|
64
|
+
},
|
|
65
|
+
"level": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"enum": ["P0", "P1", "P2", "P3"],
|
|
68
|
+
"description": "标签中的问题严重等级 level,与 P0–P3 一致;未填时在正文中沿用 severity"
|
|
69
|
+
},
|
|
70
|
+
"scenario": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"maxLength": 128,
|
|
73
|
+
"description": "场景,如 coding / writing"
|
|
74
|
+
},
|
|
75
|
+
"task_type": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"maxLength": 128,
|
|
78
|
+
"description": "任务类型"
|
|
79
|
+
},
|
|
80
|
+
"workflow_phase": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"maxLength": 128,
|
|
83
|
+
"description": "工作流阶段"
|
|
84
|
+
},
|
|
85
|
+
"affected_role": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"maxLength": 128,
|
|
88
|
+
"description": "受影响角色"
|
|
89
|
+
},
|
|
90
|
+
"primary_category": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"maxLength": 128,
|
|
93
|
+
"description": "主分类(展示用,如 model-capability)"
|
|
94
|
+
},
|
|
95
|
+
"sub_category": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"maxLength": 128,
|
|
98
|
+
"description": "次分类"
|
|
99
|
+
},
|
|
100
|
+
"classification_confidence": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"enum": ["high", "medium", "low"],
|
|
103
|
+
"description": "分类置信度"
|
|
104
|
+
},
|
|
105
|
+
"classification_rationale": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"maxLength": 800,
|
|
108
|
+
"description": "判断依据"
|
|
109
|
+
},
|
|
110
|
+
"user_id": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"maxLength": 256,
|
|
113
|
+
"description": "追踪侧用户标识(Langfuse 等);正文「日志信息」展示公司邮箱请用 user_email"
|
|
114
|
+
},
|
|
115
|
+
"user_email": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"maxLength": 256,
|
|
118
|
+
"description": "公司邮箱,展示在 Issue 正文「日志信息」user_email 行(可覆盖 metadata user.email)"
|
|
119
|
+
},
|
|
120
|
+
"observation_id": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"maxLength": 256,
|
|
123
|
+
"description": "日志信息中的 observation_id(观测关联 ID)"
|
|
124
|
+
},
|
|
125
|
+
"message_id": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"maxLength": 256,
|
|
128
|
+
"description": "OpenCode 当前消息 ID(日志信息,对应 context.messageID)"
|
|
129
|
+
},
|
|
130
|
+
"opencode_subagent": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"maxLength": 128,
|
|
133
|
+
"description": "OpenCode 子 Agent 名(日志信息,对应 context.agent,非 LLM 模型名)"
|
|
134
|
+
},
|
|
135
|
+
"tags": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"maxLength": 2000,
|
|
138
|
+
"description": "可选备注标签(正文「标签」小节固定展示 agent/model/level)"
|
|
139
|
+
},
|
|
140
|
+
"impact": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"maxLength": 2000,
|
|
143
|
+
"description": "影响说明"
|
|
144
|
+
},
|
|
145
|
+
"possible_causes": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"maxLength": 4000,
|
|
148
|
+
"description": "可能原因,多行可用换行,行首可加 - "
|
|
149
|
+
},
|
|
150
|
+
"improvement_direction": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"maxLength": 2000,
|
|
153
|
+
"description": "改进方向"
|
|
154
|
+
},
|
|
155
|
+
"suggested_follow_up": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"maxLength": 2000,
|
|
158
|
+
"description": "建议补充信息"
|
|
159
|
+
},
|
|
160
|
+
"suggested_dispatch": {
|
|
161
|
+
"type": "string",
|
|
162
|
+
"maxLength": 500,
|
|
163
|
+
"description": "建议分派方向"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-aicoding-tool",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Configure AI coding tools with Langfuse tracing and install the oh-ai-report issue feedback plugin.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"oh-aicoding-tool": "bin/cli.js",
|
|
9
|
+
"code-tool-langfuse": "bin/langfuse-cli.js",
|
|
10
|
+
"opencode-ohai-report": "opencode-ohai-report/bin/cli.js",
|
|
11
|
+
"ohai-report": "opencode-ohai-report/bin/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"scripts",
|
|
16
|
+
"opencode-ohai-report/.opencode",
|
|
17
|
+
"opencode-ohai-report/.claude",
|
|
18
|
+
"opencode-ohai-report/bin",
|
|
19
|
+
"opencode-ohai-report/docs",
|
|
20
|
+
"opencode-ohai-report/examples",
|
|
21
|
+
"opencode-ohai-report/scripts/*.py",
|
|
22
|
+
"opencode-ohai-report/scripts/*.ps1",
|
|
23
|
+
"opencode-ohai-report/scripts/*.sh",
|
|
24
|
+
"opencode-ohai-report/tools/ohai-report/ohai_report.py",
|
|
25
|
+
"opencode-ohai-report/tools/ohai-report/ohai_report/**/*.py",
|
|
26
|
+
"opencode-ohai-report/tools/ohai-report/schemas",
|
|
27
|
+
"opencode-ohai-report/tools/ohai-report/examples",
|
|
28
|
+
"opencode-ohai-report/tools/ohai-report/README.md",
|
|
29
|
+
"opencode-ohai-report/README.md",
|
|
30
|
+
"opencode-ohai-report/package.json",
|
|
31
|
+
"langfuse_hook.py",
|
|
32
|
+
"codex_langfuse_notify.py",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CODEX_LANGFUSE_PLAN.md",
|
|
35
|
+
"setup-langfuse.bat",
|
|
36
|
+
"setup-langfuse.sh"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"start": "node bin/cli.js",
|
|
40
|
+
"report": "node opencode-ohai-report/bin/cli.js",
|
|
41
|
+
"report:check": "node opencode-ohai-report/bin/cli.js doctor",
|
|
42
|
+
"report:test": "cd opencode-ohai-report && npm test",
|
|
43
|
+
"claude:setup": "node scripts/langfuse-setup.mjs",
|
|
44
|
+
"claude:check": "node scripts/langfuse-check.mjs",
|
|
45
|
+
"langfuse:setup": "node scripts/langfuse-setup.mjs",
|
|
46
|
+
"langfuse:check": "node scripts/langfuse-check.mjs",
|
|
47
|
+
"opencode:run": "node scripts/opencode-langfuse-run.mjs",
|
|
48
|
+
"opencode:setup": "node scripts/opencode-langfuse-setup.mjs",
|
|
49
|
+
"opencode:check": "node scripts/opencode-langfuse-check.mjs",
|
|
50
|
+
"opencode:langfuse:setup": "node scripts/opencode-langfuse-setup.mjs",
|
|
51
|
+
"opencode:langfuse:check": "node scripts/opencode-langfuse-check.mjs",
|
|
52
|
+
"opencode:langfuse:run": "node scripts/opencode-langfuse-run.mjs",
|
|
53
|
+
"codex:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
54
|
+
"codex:check": "node scripts/codex-langfuse-check.mjs",
|
|
55
|
+
"codex:langfuse:setup": "node scripts/codex-langfuse-setup.mjs",
|
|
56
|
+
"codex:langfuse:check": "node scripts/codex-langfuse-check.mjs"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {}
|
|
59
|
+
}
|