codebee 0.1.63 → 0.1.64
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/CHANGELOG.md +8 -0
- package/README.md +5 -2
- package/app/core/artifacts.py +148 -0
- package/app/core/bookmeta.py +215 -19
- package/app/core/modelhub.py +76 -10
- package/app/core/pipeline.py +42 -3
- package/app/core/runner.py +8 -3
- package/app/core/store.py +13 -70
- package/app/core/zentao.py +386 -29
- package/app/main.py +8 -2
- package/package.json +1 -1
package/app/core/zentao.py
CHANGED
|
@@ -32,7 +32,11 @@ backend | frontend | both | not_ours | unknown。
|
|
|
32
32
|
纯对方端问题 → 不建任务,直接转派该端负责人+排查结论评论
|
|
33
33
|
非我方 → 转派报告人(或 owners.not_ours)+评论;只转派不解决
|
|
34
34
|
unknown → 不碰 bug,need_manual + 群通知(下轮扫描 bug 仍激活则重排查)
|
|
35
|
-
修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account >
|
|
35
|
+
修复任务失败 → 评论尝试记录 + 转派该端负责人(模块路由 account > 端负责人);
|
|
36
|
+
CodeBee 自身崩溃(内部异常)不转派——那是工具故障不是修不动,
|
|
37
|
+
只评论+群通知人工;负责人未配置同样只评论。
|
|
38
|
+
只评论也绝不盲写 PUT:禅道 PUT /bugs/{id} 缺 assignedTo 会
|
|
39
|
+
清空指派人,必须带回当前指派人(#27783 案)。
|
|
36
40
|
|
|
37
41
|
落库纪律:修复任务落单即带基线(档案配了用档案,没配取工作目录当前 HEAD)
|
|
38
42
|
走任务分支隔离,修完的代码自动提交在任务分支上,对账合并后才算落库;
|
|
@@ -45,8 +49,10 @@ resolve 前再验一遍——没基线的任务改动只在工作区,有未提
|
|
|
45
49
|
"""
|
|
46
50
|
from __future__ import annotations
|
|
47
51
|
|
|
52
|
+
import base64
|
|
48
53
|
import html as _html
|
|
49
54
|
import hashlib
|
|
55
|
+
import io
|
|
50
56
|
import ipaddress
|
|
51
57
|
import json
|
|
52
58
|
import logging
|
|
@@ -92,6 +98,13 @@ SIDES = ("backend", "frontend")
|
|
|
92
98
|
TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
|
|
93
99
|
SIDE_CN = {"backend": "后端", "frontend": "前端"}
|
|
94
100
|
|
|
101
|
+
# 图片证据:#27754 案——「功能未对齐,具体如图」被纯文本排查脑补成前端样式问题。
|
|
102
|
+
# 排查/修复前先尽量把图抓到手;抓不到就按守卫留人工,绝不盲判。
|
|
103
|
+
_IMG_REF_RE = re.compile(r"如图|见图|截图|附图|下图|上图|图示|图片|图像|录屏|视频")
|
|
104
|
+
TRIAGE_MAX_IMAGES = 4 # 排查提示词最多随附几张图(请求体与 token 预算)
|
|
105
|
+
IMG_CACHE_TTL = 900 # bug 图片按 bug_id 短缓存(排查+建任务两处共用)
|
|
106
|
+
_IMG_CACHE = {} # str(bug_id) → (ts, [(mime, b64)]);空结果也缓存防重复下载
|
|
107
|
+
|
|
95
108
|
_CFG_DEFAULTS = {
|
|
96
109
|
"base_url": "",
|
|
97
110
|
"account": "",
|
|
@@ -828,7 +841,13 @@ def _old_route(api, method, p, q, body):
|
|
|
828
841
|
m = re.match(r"^/bugs/(\d+)$", p)
|
|
829
842
|
if m and method == "GET":
|
|
830
843
|
d = go(get("/bug-view-%s.json" % m.group(1)), "(查 bug)")
|
|
831
|
-
|
|
844
|
+
bug = d.get("bug") if isinstance(d, dict) and isinstance(d.get("bug"), dict) else d
|
|
845
|
+
# 老接口的 files 附件在 data 顶层,不在 bug 里:合并进 bug dict(REST 形状兼容)
|
|
846
|
+
if isinstance(bug, dict) and isinstance(d, dict) \
|
|
847
|
+
and isinstance(d.get("files"), (dict, list)):
|
|
848
|
+
bug = dict(bug)
|
|
849
|
+
bug["files"] = d["files"]
|
|
850
|
+
return bug
|
|
832
851
|
|
|
833
852
|
m = re.match(r"^/bugs/(\d+)/resolve$", p)
|
|
834
853
|
if m and method == "POST":
|
|
@@ -893,6 +912,231 @@ def _severity(bug):
|
|
|
893
912
|
return 0
|
|
894
913
|
|
|
895
914
|
|
|
915
|
+
# ---------------------------------------------------------------- bug 图片证据
|
|
916
|
+
|
|
917
|
+
_IMG_MAGIC = ((b"\x89PNG", "image/png"), (b"\xff\xd8\xff", "image/jpeg"),
|
|
918
|
+
(b"GIF8", "image/gif"))
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _img_mime(raw):
|
|
922
|
+
"""按魔数判图片类型;非图返回空串(下载到错误页 HTML 时靠它拦住)。"""
|
|
923
|
+
raw = bytes(raw or b"")
|
|
924
|
+
for pre, mime in _IMG_MAGIC:
|
|
925
|
+
if raw.startswith(pre):
|
|
926
|
+
return mime
|
|
927
|
+
if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
|
928
|
+
return "image/webp"
|
|
929
|
+
return ""
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _img_ext_mime(ext):
|
|
933
|
+
return {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
|
934
|
+
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"}.get(
|
|
935
|
+
str(ext or "").strip().lstrip(".").lower(), "")
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _img_srcs(steps_html):
|
|
939
|
+
"""steps 富文本里的 <img src> 候选(含 data: URI)。"""
|
|
940
|
+
return re.findall(r'(?is)<img\b[^>]*?\bsrc\s*=\s*["\']([^"\']+)["\']',
|
|
941
|
+
str(steps_html or ""))
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _bug_mentions_images(bug):
|
|
945
|
+
"""描述是否指向图片证据:steps 带 <img>、文字提「如图/截图…」、或附件含图片。"""
|
|
946
|
+
html = str(bug.get("steps") or "")
|
|
947
|
+
if "<img" in html.lower():
|
|
948
|
+
return True
|
|
949
|
+
text = str(bug.get("title") or "") + "\n" + _strip_html(html)
|
|
950
|
+
if _IMG_REF_RE.search(text):
|
|
951
|
+
return True
|
|
952
|
+
files = bug.get("files")
|
|
953
|
+
vals = files.values() if isinstance(files, dict) else (files if isinstance(files, list) else [])
|
|
954
|
+
for e in vals:
|
|
955
|
+
if isinstance(e, dict) and _img_ext_mime(
|
|
956
|
+
e.get("extension") or str(e.get("title") or "").rsplit(".", 1)[-1]):
|
|
957
|
+
return True
|
|
958
|
+
return False
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _decode_data_uri(src):
|
|
962
|
+
m = re.match(r"(?is)^data:(image/[\w.+-]+);base64,(.+)$", src.strip())
|
|
963
|
+
if not m:
|
|
964
|
+
return "", b""
|
|
965
|
+
try:
|
|
966
|
+
raw = base64.b64decode(m.group(2))
|
|
967
|
+
except Exception:
|
|
968
|
+
return "", b""
|
|
969
|
+
mime = _img_mime(raw) or m.group(1).lower()
|
|
970
|
+
return mime, raw
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _download_same_origin(src, cfg):
|
|
974
|
+
"""下载禅道站内图片字节:仅同源(host:port 与配置的禅道一致),带会话多形态
|
|
975
|
+
尝试(zentaosid 查询参——新老两通道都认;再裸 GET 兜底),魔数校验。
|
|
976
|
+
非 200 / 非图 / 网络失败返回 b""。"""
|
|
977
|
+
try:
|
|
978
|
+
base = _raw_base(cfg.get("base_url"))
|
|
979
|
+
except ZenError:
|
|
980
|
+
return b""
|
|
981
|
+
url = src if "://" in str(src) else base + "/" + str(src).lstrip("/")
|
|
982
|
+
try:
|
|
983
|
+
u, bu = urllib.parse.urlsplit(url), urllib.parse.urlsplit(base)
|
|
984
|
+
except ValueError:
|
|
985
|
+
return b""
|
|
986
|
+
if (u.scheme or bu.scheme, u.netloc) != (bu.scheme, bu.netloc):
|
|
987
|
+
return b"" # 第三方图床一律不取(SSRF 边界外只放行同源)
|
|
988
|
+
url = _guard_url("%s://%s%s" % (bu.scheme, bu.netloc, u.path or "/")
|
|
989
|
+
+ (("?" + u.query) if u.query else ""))
|
|
990
|
+
tries = []
|
|
991
|
+
for sid in (str(_OLD.get("sid") or ""), str(_TOKEN.get("v") or "")):
|
|
992
|
+
if sid:
|
|
993
|
+
tries.append(url + ("&" if "?" in url else "?") + "zentaosid=" + sid)
|
|
994
|
+
tries.append(url)
|
|
995
|
+
for t in tries:
|
|
996
|
+
try:
|
|
997
|
+
req = urllib.request.Request(t, headers={"Accept": "*/*"})
|
|
998
|
+
with _no_redirect_opener().open(req, timeout=HTTP_TIMEOUT) as r:
|
|
999
|
+
if r.status != 200:
|
|
1000
|
+
continue
|
|
1001
|
+
raw = r.read(20 * 1024 * 1024)
|
|
1002
|
+
except Exception:
|
|
1003
|
+
continue
|
|
1004
|
+
if _img_mime(raw):
|
|
1005
|
+
return raw
|
|
1006
|
+
return b""
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _file_entry_urls(fid, entry):
|
|
1010
|
+
"""详情 files 条目 → 下载候选 URL(entry 自带地址优先,再猜经典路由)。"""
|
|
1011
|
+
out = []
|
|
1012
|
+
if isinstance(entry, dict):
|
|
1013
|
+
for k in ("download", "url", "href"):
|
|
1014
|
+
v = str(entry.get(k) or "").strip()
|
|
1015
|
+
if v.startswith(("http://", "https://", "/")):
|
|
1016
|
+
out.append(v)
|
|
1017
|
+
ext = _img_ext_mime(entry.get("extension")
|
|
1018
|
+
or str(entry.get("title") or "").rsplit(".", 1)[-1])
|
|
1019
|
+
else:
|
|
1020
|
+
ext = ""
|
|
1021
|
+
if ext:
|
|
1022
|
+
ext = ext.split("/", 1)[1]
|
|
1023
|
+
for route in ("file-read-%s.%s", "file-download-%s.%s"):
|
|
1024
|
+
out.append("/" + route % (fid, ext))
|
|
1025
|
+
return out
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _prep_image_pairs(pairs):
|
|
1029
|
+
"""[(mime, 原始字节)] → [(mime, b64)]。缩放规则与内置智能体同款(长边 1568、
|
|
1030
|
+
超大转 JPEG q85、单张 5MB 剔除);就地实现不跨层 import(架构 L2→L1 反向禁)。"""
|
|
1031
|
+
try:
|
|
1032
|
+
from PIL import Image
|
|
1033
|
+
except Exception:
|
|
1034
|
+
Image = None
|
|
1035
|
+
out = []
|
|
1036
|
+
for mime, raw in pairs or []:
|
|
1037
|
+
data = raw
|
|
1038
|
+
if Image is not None:
|
|
1039
|
+
try:
|
|
1040
|
+
im = Image.open(io.BytesIO(raw))
|
|
1041
|
+
im.load()
|
|
1042
|
+
w, h = im.size
|
|
1043
|
+
edge = max(w, h, 1)
|
|
1044
|
+
if edge > 1568:
|
|
1045
|
+
r = 1568 / float(edge)
|
|
1046
|
+
im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
|
|
1047
|
+
Image.LANCZOS)
|
|
1048
|
+
fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
|
|
1049
|
+
"image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
|
|
1050
|
+
buf = io.BytesIO()
|
|
1051
|
+
im.save(buf, format=fmt)
|
|
1052
|
+
data = buf.getvalue()
|
|
1053
|
+
except Exception:
|
|
1054
|
+
data = raw # 解码失败退回原始字节
|
|
1055
|
+
if mime != "image/jpeg" and len(data) > 3500 * 1024:
|
|
1056
|
+
try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
|
|
1057
|
+
im = Image.open(io.BytesIO(data)).convert("RGB")
|
|
1058
|
+
buf = io.BytesIO()
|
|
1059
|
+
im.save(buf, format="JPEG", quality=85)
|
|
1060
|
+
data, mime = buf.getvalue(), "image/jpeg"
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
if len(data) > 5 * 1024 * 1024:
|
|
1064
|
+
continue
|
|
1065
|
+
out.append((mime, base64.b64encode(data).decode("ascii")))
|
|
1066
|
+
return out
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _bug_images(bug_id, steps_html, cfg=None):
|
|
1070
|
+
"""bug 关联图片 → [(mime, b64)]:steps 内嵌 <img>(含 data: URI)优先,
|
|
1071
|
+
读不到再看详情 files 附件。下载带禅道会话、魔数校验、缩放管线同内置智能体。
|
|
1072
|
+
拿不到返回 [](也缓存,15 分钟内不重复下载)。"""
|
|
1073
|
+
key = str(bug_id or "")
|
|
1074
|
+
now = time.time()
|
|
1075
|
+
hit = _IMG_CACHE.get(key)
|
|
1076
|
+
if hit and now - hit[0] < IMG_CACHE_TTL:
|
|
1077
|
+
return hit[1]
|
|
1078
|
+
cfg = cfg or _cfg()
|
|
1079
|
+
pairs = []
|
|
1080
|
+
for src in _img_srcs(steps_html):
|
|
1081
|
+
s = src.strip()
|
|
1082
|
+
if s.lower().startswith("data:"):
|
|
1083
|
+
mime, raw = _decode_data_uri(s)
|
|
1084
|
+
if raw:
|
|
1085
|
+
pairs.append((mime or "image/png", raw))
|
|
1086
|
+
elif not s.lower().startswith("javascript:"):
|
|
1087
|
+
raw = _download_same_origin(s, cfg)
|
|
1088
|
+
if raw:
|
|
1089
|
+
pairs.append((_img_mime(raw), raw))
|
|
1090
|
+
if not pairs and key:
|
|
1091
|
+
try:
|
|
1092
|
+
detail = _call("GET", "/bugs/%s" % key, cfg=cfg)
|
|
1093
|
+
files = detail.get("files") if isinstance(detail, dict) else None
|
|
1094
|
+
entries = (list(files.items()) if isinstance(files, dict)
|
|
1095
|
+
else [(str(i), e) for i, e in enumerate(files or [])])
|
|
1096
|
+
for fid, entry in entries:
|
|
1097
|
+
mime = _img_ext_mime(
|
|
1098
|
+
(entry.get("extension") if isinstance(entry, dict) else "")
|
|
1099
|
+
or str(entry.get("title") if isinstance(entry, dict) else "").rsplit(".", 1)[-1])
|
|
1100
|
+
if not mime:
|
|
1101
|
+
continue # 日志/压缩包等非图附件不取
|
|
1102
|
+
for u in _file_entry_urls(fid, entry):
|
|
1103
|
+
raw = _download_same_origin(u, cfg)
|
|
1104
|
+
if raw:
|
|
1105
|
+
pairs.append((_img_mime(raw), raw))
|
|
1106
|
+
break
|
|
1107
|
+
except Exception:
|
|
1108
|
+
log.debug("zentao: bug 附件图片获取失败", exc_info=True)
|
|
1109
|
+
imgs = _prep_image_pairs(pairs[:TRIAGE_MAX_IMAGES])
|
|
1110
|
+
if len(_IMG_CACHE) > 128:
|
|
1111
|
+
for k in sorted(_IMG_CACHE, key=lambda k: _IMG_CACHE[k][0])[:64]:
|
|
1112
|
+
_IMG_CACHE.pop(k, None)
|
|
1113
|
+
_IMG_CACHE[key] = (now, imgs)
|
|
1114
|
+
return imgs
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _save_bug_attachments(workdir, bug_id, imgs):
|
|
1118
|
+
"""bug 截图落工作目录 _attachments/(create_task 的 dict 清单形态直用)。
|
|
1119
|
+
返回 [{name,size,mime,path}];失败返回 []。"""
|
|
1120
|
+
if not imgs:
|
|
1121
|
+
return []
|
|
1122
|
+
try:
|
|
1123
|
+
adir = Path(workdir) / "_attachments"
|
|
1124
|
+
adir.mkdir(parents=True, exist_ok=True)
|
|
1125
|
+
ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
|
|
1126
|
+
"image/webp": ".webp"}
|
|
1127
|
+
items = []
|
|
1128
|
+
for i, (mime, b64) in enumerate(imgs, 1):
|
|
1129
|
+
name = "zenbug%s-%d%s" % (bug_id, i, ext.get(mime, ".png"))
|
|
1130
|
+
data = base64.b64decode(b64)
|
|
1131
|
+
(adir / name).write_bytes(data)
|
|
1132
|
+
items.append({"name": name, "size": len(data), "mime": mime,
|
|
1133
|
+
"path": "_attachments/" + name})
|
|
1134
|
+
return items
|
|
1135
|
+
except Exception:
|
|
1136
|
+
log.debug("zentao: bug 截图落附件失败", exc_info=True)
|
|
1137
|
+
return []
|
|
1138
|
+
|
|
1139
|
+
|
|
896
1140
|
def _claimable(bug, profile):
|
|
897
1141
|
"""认领过滤:active + 指派 + 严重度(产品由档案本身界定)。"""
|
|
898
1142
|
if str(bug.get("status") or "") != "active":
|
|
@@ -1129,13 +1373,27 @@ def test_connection(base_url=None, account=None, password=None):
|
|
|
1129
1373
|
# ---------------------------------------------------------------- 排查(triage)
|
|
1130
1374
|
|
|
1131
1375
|
def _ai_triage(bug, profile):
|
|
1132
|
-
"""AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。
|
|
1376
|
+
"""AI 兜底排查:单次 LLM 调用判端。不可用/解析失败返回 None(bookmeta 同款配方)。
|
|
1377
|
+
|
|
1378
|
+
图片守卫(#27754 案):描述指向截图而图片读不到 → unknown 留人工,绝不拿
|
|
1379
|
+
纯文本盲判。排查模型不会看图时也不甩人工——自动全库找看图候选(声明
|
|
1380
|
+
image_in 的优先,名字推断兜底),候选逐个试、实测吃图成功回写能力标记。
|
|
1381
|
+
"""
|
|
1133
1382
|
try:
|
|
1134
1383
|
from . import modelhub, runner
|
|
1384
|
+
images = []
|
|
1385
|
+
if _bug_mentions_images(bug):
|
|
1386
|
+
try:
|
|
1387
|
+
images = _bug_images(bug.get("id"), bug.get("steps"))
|
|
1388
|
+
except Exception:
|
|
1389
|
+
log.debug("zentao: bug 图片获取失败", exc_info=True)
|
|
1390
|
+
if not images:
|
|
1391
|
+
return {"side": "unknown",
|
|
1392
|
+
"reason": "描述指向截图/图片但图片未能读取,证据不足,"
|
|
1393
|
+
"留人工确认后定责",
|
|
1394
|
+
"imgs": 0}
|
|
1135
1395
|
orch = modelhub.resolve_orchestrator()
|
|
1136
|
-
if
|
|
1137
|
-
return None
|
|
1138
|
-
prov, model = orch
|
|
1396
|
+
prov, model = orch if orch else (None, None)
|
|
1139
1397
|
repos = profile.get("repos") or {}
|
|
1140
1398
|
hints = profile.get("repo_hints") or {}
|
|
1141
1399
|
|
|
@@ -1153,6 +1411,9 @@ def _ai_triage(bug, profile):
|
|
|
1153
1411
|
steps = _strip_html(bug.get("steps"))
|
|
1154
1412
|
if steps:
|
|
1155
1413
|
lines.append(steps[:3000])
|
|
1414
|
+
if images:
|
|
1415
|
+
lines.append("【截图】随本消息附 %d 张 bug 截图,判定前先看图,"
|
|
1416
|
+
"截图与文字冲突时以截图为准。" % len(images))
|
|
1156
1417
|
lines.append("")
|
|
1157
1418
|
lines.append("【仓库背景】后端仓库:%s;前端仓库:%s" % (_repo_desc("backend"),
|
|
1158
1419
|
_repo_desc("frontend")))
|
|
@@ -1161,16 +1422,49 @@ def _ai_triage(bug, profile):
|
|
|
1161
1422
|
'"reason": "一句话依据"}')
|
|
1162
1423
|
lines.append("判定口径:backend=纯后端问题;frontend=纯前端问题;both=两端都要改;"
|
|
1163
1424
|
"not_ours=与这两个仓库无关(第三方服务/环境/需求变更/数据问题等)。")
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1425
|
+
prompt = "\n".join(lines)
|
|
1426
|
+
if images:
|
|
1427
|
+
cand = modelhub.vision_candidates(
|
|
1428
|
+
prefer_provider_id=(prov or {}).get("id") or "",
|
|
1429
|
+
prefer_model=model or "")
|
|
1430
|
+
if not cand:
|
|
1431
|
+
return {"side": "unknown",
|
|
1432
|
+
"reason": "所有已启用模型都不支持图片输入,无法看图定责"
|
|
1433
|
+
"(请在模型管理里启用一个视觉模型)",
|
|
1434
|
+
"imgs": 0}
|
|
1435
|
+
res, last_err = None, ""
|
|
1436
|
+
for p, m, inferred in cand:
|
|
1437
|
+
r = modelhub.chat(p["id"], m, prompt, max_tokens=500, timeout=90,
|
|
1438
|
+
images=images)
|
|
1439
|
+
if r.get("ok"):
|
|
1440
|
+
res = r
|
|
1441
|
+
if inferred:
|
|
1442
|
+
# 名字推断的候选实测吃图成功:回写能力标记(幂等),
|
|
1443
|
+
# 模型管理页图徽章同步可见,后续调用直接命中
|
|
1444
|
+
err = modelhub.set_model_caps(p["id"], m, True)
|
|
1445
|
+
if err:
|
|
1446
|
+
log.debug("zentao: 自动标记 image_in 失败:%s", err)
|
|
1447
|
+
break
|
|
1448
|
+
last_err = str(r.get("error") or "")
|
|
1449
|
+
log.warning("zentao: 视觉排查候选 %s/%s 不可用:%s",
|
|
1450
|
+
p.get("id"), m, last_err[:160])
|
|
1451
|
+
if not res:
|
|
1452
|
+
return {"side": "unknown",
|
|
1453
|
+
"reason": "看图排查全部候选失败(%s)" % last_err[:160],
|
|
1454
|
+
"imgs": 0}
|
|
1455
|
+
else:
|
|
1456
|
+
if not orch:
|
|
1457
|
+
return None
|
|
1458
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=500, timeout=90)
|
|
1459
|
+
if not res.get("ok"):
|
|
1460
|
+
log.warning("zentao: AI 排查失败:%s", res.get("error"))
|
|
1461
|
+
return None
|
|
1169
1462
|
data = runner.extract_json(res.get("text") or "")
|
|
1170
1463
|
side = str((data or {}).get("side") or "").strip().lower()
|
|
1171
1464
|
if side not in TRIAGE_SIDES:
|
|
1172
1465
|
return None
|
|
1173
|
-
return {"side": side, "reason": str((data or {}).get("reason") or "")[:300]
|
|
1466
|
+
return {"side": side, "reason": str((data or {}).get("reason") or "")[:300],
|
|
1467
|
+
"imgs": len(images)}
|
|
1174
1468
|
except Exception:
|
|
1175
1469
|
log.debug("zentao: AI 排查异常", exc_info=True)
|
|
1176
1470
|
return None
|
|
@@ -1206,8 +1500,8 @@ def _repo_of(profile, side):
|
|
|
1206
1500
|
return (profile.get("repos") or {}).get(side) or dict(_REPO_DEFAULTS)
|
|
1207
1501
|
|
|
1208
1502
|
|
|
1209
|
-
def _goal_text(bug, side=None):
|
|
1210
|
-
"""bug → 修复目标提示词。side
|
|
1503
|
+
def _goal_text(bug, side=None, images=0):
|
|
1504
|
+
"""bug → 修复目标提示词。side 给出时附端约束;images>0 时提示先读附件截图。"""
|
|
1211
1505
|
bid = bug.get("id")
|
|
1212
1506
|
lines = ["修复禅道 Bug #%s:%s" % (bid, str(bug.get("title") or "").strip())]
|
|
1213
1507
|
steps = _strip_html(bug.get("steps"))
|
|
@@ -1233,6 +1527,11 @@ def _goal_text(bug, side=None):
|
|
|
1233
1527
|
if meta:
|
|
1234
1528
|
lines.append("")
|
|
1235
1529
|
lines.append("【元信息】" + ";".join(meta))
|
|
1530
|
+
if images:
|
|
1531
|
+
lines.append("")
|
|
1532
|
+
lines.append("【截图】bug 截图共 %d 张,已放在工作目录 _attachments/(文件名 "
|
|
1533
|
+
"zenbug*);先读图再动手——需求以截图为准,不要凭文字想象。"
|
|
1534
|
+
% images)
|
|
1236
1535
|
lines.append("")
|
|
1237
1536
|
lines.append("【要求】只修这个 bug,不做无关重构;改动最小化;修完自查不引入回归。")
|
|
1238
1537
|
if side in SIDES:
|
|
@@ -1250,7 +1549,18 @@ def _launch_fix(bug, profile, side, cfg):
|
|
|
1250
1549
|
wd = str(repo.get("workdir") or "").strip() or settings.default_workdir()
|
|
1251
1550
|
title = ("[禅道#%s][%s] %s" % (bid, SIDE_CN.get(side, side),
|
|
1252
1551
|
str(bug.get("title") or "").strip())).strip()[:60]
|
|
1253
|
-
|
|
1552
|
+
# bug 截图随任务走:修复智能体跟排查同样需要看图,否则修出来的就是想象中的 bug
|
|
1553
|
+
imgs = []
|
|
1554
|
+
try:
|
|
1555
|
+
if _bug_mentions_images(bug):
|
|
1556
|
+
imgs = _bug_images(bid, bug.get("steps"))
|
|
1557
|
+
except Exception:
|
|
1558
|
+
imgs = []
|
|
1559
|
+
atts = _save_bug_attachments(str(wd), bid, imgs)
|
|
1560
|
+
payload = {"type": "code", "title": title, "goal": _goal_text(bug, side, images=len(atts)),
|
|
1561
|
+
"workdir": wd}
|
|
1562
|
+
if atts:
|
|
1563
|
+
payload["attachments"] = atts
|
|
1254
1564
|
base = str(repo.get("git_rev") or "").strip()
|
|
1255
1565
|
if not base:
|
|
1256
1566
|
# 档案没配基线就取落单时点的 HEAD:让修复走任务分支隔离——修完自动
|
|
@@ -1354,6 +1664,8 @@ def _transfer_text(claim, profile, fixed_runs, target_side):
|
|
|
1354
1664
|
"Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or ""),
|
|
1355
1665
|
"排查结论:%s问题——%s" % (SIDE_CN.get(target_side, target_side),
|
|
1356
1666
|
tri.get("reason") or tri.get("by") or "按规则")]
|
|
1667
|
+
if tri.get("imgs"):
|
|
1668
|
+
lines.append("(判定依据含 %d 张 bug 截图)" % tri["imgs"])
|
|
1357
1669
|
for side, run in (fixed_runs or []):
|
|
1358
1670
|
lines.extend(_fix_summary(claim, run, profile, side))
|
|
1359
1671
|
lines.append("请%s负责人接手处理;本 bug 保持激活,处理完请按正常流程解决。"
|
|
@@ -1362,7 +1674,7 @@ def _transfer_text(claim, profile, fixed_runs, target_side):
|
|
|
1362
1674
|
return "\n".join(lines)
|
|
1363
1675
|
|
|
1364
1676
|
|
|
1365
|
-
def _fail_text(claim, failed_tasks, runs):
|
|
1677
|
+
def _fail_text(claim, failed_tasks, runs, internal=False):
|
|
1366
1678
|
tri = claim.get("triage") or {}
|
|
1367
1679
|
lines = ["【CodeBee 自动修复未成功】",
|
|
1368
1680
|
"Bug:#%s %s" % (claim.get("bug_id"), claim.get("title") or "")]
|
|
@@ -1372,6 +1684,9 @@ def _fail_text(claim, failed_tasks, runs):
|
|
|
1372
1684
|
r = runs.get(t.get("run_id")) or {}
|
|
1373
1685
|
why = str(r.get("error") or "").strip() or ("运行状态 " + str(r.get("status") or ""))
|
|
1374
1686
|
lines.append("【%s】失败:%s" % (SIDE_CN.get(t.get("side"), t.get("side")), why[:300]))
|
|
1687
|
+
if internal:
|
|
1688
|
+
lines.append("(失败原因是 CodeBee 工具自身异常,不代表修复结论;"
|
|
1689
|
+
"请人工排查 CodeBee 或续跑修复任务)")
|
|
1375
1690
|
lines.append("CodeBee 修复任务:%s(可人工续跑或接管);本 bug 保持待处理。"
|
|
1376
1691
|
% "、".join(t.get("task_id") or "?" for t in claim.get("tasks") or []))
|
|
1377
1692
|
return "\n".join(lines)
|
|
@@ -1400,10 +1715,18 @@ def _ensure_resolved(cfg, bug_id, comment, assign_to):
|
|
|
1400
1715
|
|
|
1401
1716
|
|
|
1402
1717
|
def _transfer(cfg, bug_id, target, comment):
|
|
1403
|
-
"""转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。
|
|
1718
|
+
"""转派:PUT assignedTo+comment(bug 保持激活)。target 空=只评论。抛 ZenError。
|
|
1719
|
+
|
|
1720
|
+
禅道 PUT /bugs/{id} 不带 assignedTo 会把指派人清空(#27783 案:只评论
|
|
1721
|
+
也出了「指派给(空)」记录),空 target 必须先 GET 带回当前指派人;GET
|
|
1722
|
+
不到就整个不发——宁可不评论,不可盲写清指派。
|
|
1723
|
+
"""
|
|
1404
1724
|
body = {"comment": comment}
|
|
1405
1725
|
if target:
|
|
1406
1726
|
body["assignedTo"] = target
|
|
1727
|
+
else:
|
|
1728
|
+
cur = _call("GET", "/bugs/%s" % bug_id, cfg=cfg)
|
|
1729
|
+
body["assignedTo"] = _acct(cur.get("assignedTo")) or ""
|
|
1407
1730
|
_call("PUT", "/bugs/%s" % bug_id, cfg=cfg, body=body)
|
|
1408
1731
|
|
|
1409
1732
|
|
|
@@ -1580,8 +1903,27 @@ def _finish_ok(claim, cfg):
|
|
|
1580
1903
|
% (bid, claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
|
|
1581
1904
|
|
|
1582
1905
|
|
|
1906
|
+
# CodeBee 自身崩溃形态:run.error 是裸 Python 异常 repr(如 NameError(...),
|
|
1907
|
+
# 0.1.63 _TRANSIENT 缩进事故实测形态)或带 Python traceback。这不是「修不动
|
|
1908
|
+
# bug」——转派给端负责人等于拿自家工具故障甩锅。冒号形态(TimeoutError: x)
|
|
1909
|
+
# 多为 CLI/被测仓的真实输出,不算内部崩溃。
|
|
1910
|
+
_CRASH_REPR_RE = re.compile(r"^[A-Za-z_]\w*(?:Error|Exception|Interrupt)\(")
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _looks_internal_crash(err):
|
|
1914
|
+
e = str(err or "").strip()
|
|
1915
|
+
if not e:
|
|
1916
|
+
return False
|
|
1917
|
+
return ("Traceback (most recent call last" in e
|
|
1918
|
+
or _CRASH_REPR_RE.match(e) is not None)
|
|
1919
|
+
|
|
1920
|
+
|
|
1583
1921
|
def _finish_failed(claim, cfg):
|
|
1584
|
-
"""我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。
|
|
1922
|
+
"""我方任一任务失败:评论尝试记录 + 转派该端负责人(有配则转)。
|
|
1923
|
+
|
|
1924
|
+
CodeBee 自身崩溃(内部异常)不转派——工具故障不是「修不动」,转出去是
|
|
1925
|
+
甩锅,只评论+群通知人工。负责人未配置同样只评论。
|
|
1926
|
+
"""
|
|
1585
1927
|
bid = str(claim.get("bug_id"))
|
|
1586
1928
|
profile = _profile_for(cfg, claim.get("product")) or {}
|
|
1587
1929
|
tasks = claim.get("tasks") or []
|
|
@@ -1589,24 +1931,37 @@ def _finish_failed(claim, cfg):
|
|
|
1589
1931
|
failed = [t for t in tasks
|
|
1590
1932
|
if str((runs.get(t.get("run_id")) or {}).get("status") or "") not in
|
|
1591
1933
|
("queued", "running", "done")]
|
|
1592
|
-
|
|
1593
|
-
|
|
1934
|
+
internal = any(_looks_internal_crash((runs.get(t.get("run_id")) or {}).get("error"))
|
|
1935
|
+
for t in failed)
|
|
1936
|
+
text = _fail_text(claim, failed, runs, internal=internal)
|
|
1937
|
+
# 升级目标:取第一个失败端的路由账号/负责人(内部崩溃不转派)
|
|
1594
1938
|
target = ""
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1939
|
+
if not internal:
|
|
1940
|
+
for t in failed:
|
|
1941
|
+
target = _route_account(profile, claim.get("triage") or {}, t.get("side"))
|
|
1942
|
+
if target:
|
|
1943
|
+
break
|
|
1599
1944
|
try:
|
|
1600
1945
|
_transfer(cfg, bid, target, text)
|
|
1601
|
-
|
|
1602
|
-
|
|
1946
|
+
if internal:
|
|
1947
|
+
note = "CodeBee 内部异常,已评论说明(不转派,请人工排查工具链)"
|
|
1948
|
+
state = "commented"
|
|
1949
|
+
elif target:
|
|
1950
|
+
note = "已评论说明并转派 %s" % target
|
|
1951
|
+
state = "escalated"
|
|
1952
|
+
else:
|
|
1953
|
+
side = str((failed[0].get("side") if failed else "") or "")
|
|
1954
|
+
note = "已评论说明(%s负责人未配置,未转派)" % SIDE_CN.get(side, side or "该端")
|
|
1955
|
+
state = "commented"
|
|
1603
1956
|
except ZenError as e:
|
|
1604
1957
|
log.warning("zentao: bug %s 失败评论未送达(群通知兜底):%s", bid, e)
|
|
1605
1958
|
note = "修复失败,评论未送达:%s" % e
|
|
1606
1959
|
state = "commented"
|
|
1607
1960
|
_set_claim(bid, state=state, note=note)
|
|
1608
|
-
|
|
1609
|
-
|
|
1961
|
+
why = ("CodeBee 内部异常,未转派" if internal
|
|
1962
|
+
else ("已转派 " + target if target else "负责人未配置,未转派"))
|
|
1963
|
+
_notify("🐛❌ 禅道 Bug #%s 自动修复未成功(%s)\n%s\n修复任务:%s"
|
|
1964
|
+
% (bid, why,
|
|
1610
1965
|
claim.get("title") or "", "、".join(t.get("task_id") or "" for t in tasks)))
|
|
1611
1966
|
|
|
1612
1967
|
|
|
@@ -1692,8 +2047,9 @@ def _route_one(bug, profile, cfg, notify=True):
|
|
|
1692
2047
|
target = tri.get("account") or str(profile.get("owners").get("not_ours") or "") \
|
|
1693
2048
|
or opened
|
|
1694
2049
|
text = ("【CodeBee 排查转派】\nBug:#%s %s\n排查结论:非我方两个仓库的问题——%s\n"
|
|
1695
|
-
"转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
|
|
2050
|
+
"%s转回 %s 核实处理。\n(本条由 CodeBee 禅道集成自动回写)"
|
|
1696
2051
|
% (bid, bug.get("title") or "", tri.get("reason") or "按规则",
|
|
2052
|
+
("(判定依据含 %d 张 bug 截图)\n" % tri["imgs"]) if tri.get("imgs") else "",
|
|
1697
2053
|
target or "报告人"))
|
|
1698
2054
|
try:
|
|
1699
2055
|
_transfer(cfg, bid, target, text)
|
|
@@ -2087,4 +2443,5 @@ def _test_reset():
|
|
|
2087
2443
|
_OLD.update(api="", sid="", at=0.0)
|
|
2088
2444
|
_OLD_FORM["form"] = ""
|
|
2089
2445
|
_REST_LAST_ERR["msg"] = ""
|
|
2446
|
+
_IMG_CACHE.clear()
|
|
2090
2447
|
globals()["_LOADED"] = True
|
package/app/main.py
CHANGED
|
@@ -411,7 +411,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
411
411
|
run = store.get_run(m.group(1))
|
|
412
412
|
if not run:
|
|
413
413
|
return self._json(404, {"error": "not found"})
|
|
414
|
-
wd, files = store.run_artifacts(m.group(1))
|
|
414
|
+
wd, files = store.run_artifacts(m.group(1), cache=True)
|
|
415
415
|
# 任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
|
|
416
416
|
# 文件变动是并行活动的噪音,不算这个任务的成品
|
|
417
417
|
if files and not store.task_step_count(run.get("task_id") or ""):
|
|
@@ -2035,7 +2035,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2035
2035
|
duration_s = step_duration_s
|
|
2036
2036
|
wd, files = "", []
|
|
2037
2037
|
try:
|
|
2038
|
-
wd, files = store.run_artifacts(latest.get("id") or "", limit=12)
|
|
2038
|
+
wd, files = store.run_artifacts(latest.get("id") or "", limit=12, cache=True)
|
|
2039
2039
|
if files and not store.task_step_count(latest.get("task_id") or ""):
|
|
2040
2040
|
files = [] # 与 /files 端点同口径:无步骤的任务不给成品(fixture 防误报)
|
|
2041
2041
|
except Exception:
|
|
@@ -2645,6 +2645,12 @@ def main():
|
|
|
2645
2645
|
n_bo = bookmeta.recover_orphans() # 作品信息生成线程同样会被重启杀掉,遗留 running 收尸
|
|
2646
2646
|
if n_bo:
|
|
2647
2647
|
print("[CodeBee] 崩溃恢复:%d 条作品信息生成中断标记为 failed(可点重试)" % n_bo)
|
|
2648
|
+
try:
|
|
2649
|
+
n_bm = bookmeta.repair_existing()
|
|
2650
|
+
if n_bm:
|
|
2651
|
+
print("[CodeBee] 作品信息迁移:%d 条历史记录已补齐建书必填字段" % n_bm)
|
|
2652
|
+
except Exception:
|
|
2653
|
+
pass
|
|
2648
2654
|
try:
|
|
2649
2655
|
from core.publish import manager as publish_mgr
|
|
2650
2656
|
n_pb = publish_mgr.recover_orphans() # 发布线程同款收尸:waiting_login/busy 改判 error
|
package/package.json
CHANGED