From 48102ca2e2fc243c59c2a9606f73f6f39bfa0709 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 07:41:06 +0800 Subject: [PATCH 01/12] ci: add CI failure diagnosis script with auto-classification --- scripts/ci/ci_failure_diagnosis.py | 474 +++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 scripts/ci/ci_failure_diagnosis.py diff --git a/scripts/ci/ci_failure_diagnosis.py b/scripts/ci/ci_failure_diagnosis.py new file mode 100644 index 000000000..570e6f8c1 --- /dev/null +++ b/scripts/ci/ci_failure_diagnosis.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""CI失败诊断增强脚本:自动分类失败原因 + 提取关键错误 + 给出修复建议。 + +支持的失败类型: +1. Lint/格式问题 (ruff/black/eslint/prettier) +2. 单元测试失败 +3. Docker构建失败 +4. 依赖安装失败 (pip/npm) +5. 超时 +6. 缓存问题 +7. 数据库/迁移问题 +8. 网络问题 +9. 其他 + +用法: + python3 scripts/ci/ci_failure_diagnosis.py [--job-name "Job Name"] [--log-file /path/to/log] + +如果不传--log-file,会尝试从Gitea API获取失败job的日志。 +""" + +import json +import os +import re +import sys +import urllib.request +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class FailureDiagnosis: + """失败诊断结果""" + category: str # 失败分类 + category_cn: str # 中文分类名 + severity: str # 严重程度: high / medium / low + summary: str # 一句话摘要 + error_lines: List[str] = field(default_factory=list) # 关键错误行 + suggestions: List[str] = field(default_factory=list) # 修复建议 + auto_fixable: bool = False # 是否可以自动修复 + related_docs: str = "" # 相关文档链接 + + +# ============================================================ +# 失败模式定义 +# ============================================================ + +FAILURE_PATTERNS = [ + # ===== Lint / 格式问题 ===== + { + "pattern": r"(ruff|black|isort)\b.*(error|failed|Error)", + "category": "lint_python", + "category_cn": "Python代码质量检查", + "severity": "low", + "summary_contains": ["ruff", "black", "isort"], + "suggestions": [ + "本地运行 `black . && isort . && ruff check --fix .` 自动修复", + "使用 `scripts/agent-commit.sh` 提交(自动格式化)", + "如确认无误,可加 `# noqa: xxx` 忽略特定规则" + ], + "auto_fixable": True, + }, + { + "pattern": r"ESLint|prettier|eslint", + "category": "lint_frontend", + "category_cn": "前端代码检查", + "severity": "low", + "summary_contains": ["eslint", "prettier"], + "suggestions": [ + "本地运行 `cd apps/web && npm run lint:fix` 自动修复", + "Prettier问题: `cd apps/web && npx prettier --write .`" + ], + "auto_fixable": True, + }, + { + "pattern": r"F\d{3}|E\d{3}|W\d{3}.*ruff|ruff.*F\d{3}", + "category": "lint_python", + "category_cn": "Python代码质量检查", + "severity": "low", + "suggestions": [ + "F401: 删除未使用的import", + "F841: 删除未使用的变量或加下划线前缀", + "E501: 行超长,加 `# noqa: E501`", + "F811: 删重复import", + "运行 `ruff check --fix .` 自动修复大部分问题" + ], + "auto_fixable": True, + }, + + # ===== 单元测试失败 ===== + { + "pattern": r"FAILED|assert.*Error|AssertionError", + "category": "unit_test", + "category_cn": "单元测试失败", + "severity": "high", + "suggestions": [ + "检查相关测试文件,确认是代码问题还是测试用例问题", + "本地运行对应测试:`pytest path/to/test.py -v`", + "如测试依赖外部服务,检查mock是否正确" + ], + "auto_fixable": False, + }, + { + "pattern": r"pytest.*failed|\d+ failed.*\d+ passed", + "category": "unit_test", + "category_cn": "单元测试失败", + "severity": "high", + "suggestions": [ + "查看上方日志中的FAILED测试用例", + "检查失败断言的期望值 vs 实际值", + "新代码影响了现有测试行为,确认是预期内变更吗?" + ], + "auto_fixable": False, + }, + + # ===== Docker 构建失败 ===== + { + "pattern": r"Dockerfile.*not found|docker build.*failed|ERROR: failed to solve", + "category": "docker_build", + "category_cn": "Docker构建失败", + "severity": "high", + "suggestions": [ + "检查Dockerfile语法是否正确", + "检查引用的基础镜像是否存在", + "本地运行 `docker build -f path/to/Dockerfile .` 复现" + ], + "auto_fixable": False, + }, + { + "pattern": r"manifest.*not found|no such image|image.*not found", + "category": "docker_build", + "category_cn": "镜像不存在", + "severity": "medium", + "suggestions": [ + "检查基础镜像名称和tag是否正确", + "确认镜像仓库可访问,登录是否有效", + "如为新基础镜像,需先手动构建一次基础镜像" + ], + "auto_fixable": False, + }, + { + "pattern": r"ETXTBSY|text file busy", + "category": "docker_build", + "category_cn": "文件锁冲突(ETXTBSY)", + "severity": "low", + "summary": "esbuild并发构建冲突,重试即可", + "suggestions": [ + "偶发问题,点击Rerun重新运行即可", + "如频繁出现,检查是否有多个job并发写入同一文件" + ], + "auto_fixable": True, + }, + + # ===== 依赖安装失败 ===== + { + "pattern": r"pip install.*error|Could not find a version|No matching distribution", + "category": "dependency", + "category_cn": "pip依赖安装失败", + "severity": "medium", + "suggestions": [ + "检查requirements.txt中的版本号是否正确", + "如为新版本刚发布,可能源还没同步,稍后重试", + "检查网络连接,可尝试切换pip镜像源" + ], + "auto_fixable": False, + }, + { + "pattern": r"npm.*ERR|npm install.*failed|E404|ECONNREFUSED.*npm", + "category": "dependency", + "category_cn": "npm依赖安装失败", + "severity": "medium", + "suggestions": [ + "检查package.json中的版本号是否存在", + "网络问题:检查npm registry是否可访问", + "国内网络建议配置npmmirror镜像源" + ], + "auto_fixable": False, + }, + { + "pattern": r"Connection refused|timed out|network.*unreachable", + "category": "network", + "category_cn": "网络问题", + "severity": "medium", + "summary": "网络连接失败,可能是源站问题或DNS问题", + "suggestions": [ + "点击Rerun重试,网络问题通常是临时的", + "如持续失败,检查对应服务是否正常", + "检查Runner网络配置" + ], + "auto_fixable": True, + }, + + # ===== 超时 ===== + { + "pattern": r"timeout|timed out|exceeded.*time limit|job.*cancelled.*timeout", + "category": "timeout", + "category_cn": "执行超时", + "severity": "medium", + "suggestions": [ + "如首次出现:重试一次,可能是临时性能波动", + "频繁出现:检查构建是否变慢了,最近是否加了新依赖", + "可适当增加timeout-minutes配置" + ], + "auto_fixable": False, + }, + + # ===== 数据库/迁移 ===== + { + "pattern": r"alembic.*error|migration.*failed|relation.*does not exist|column.*does not exist", + "category": "migration", + "category_cn": "数据库迁移失败", + "severity": "high", + "suggestions": [ + "检查迁移脚本是否正确,down_revision是否对", + "确认数据库中是否有脏数据或残留表", + "迁移脚本合并冲突时,重新生成迁移文件" + ], + "auto_fixable": False, + }, + + # ===== 缓存问题 ===== + { + "pattern": r"cache.*corrupt|cache.*invalid|snapshot.*not found|failed to compute cache key", + "category": "cache", + "category_cn": "缓存损坏", + "severity": "low", + "suggestions": [ + "构建系统会自动清理损坏缓存并重试,通常无需干预", + "如持续失败,手动清理Runner上的缓存目录" + ], + "auto_fixable": True, + }, + + # ===== Checkout 失败 ===== + { + "pattern": r"Could not resolve host|fatal:.*repository|SSL.*problem", + "category": "checkout", + "category_cn": "代码拉取失败", + "severity": "low", + "suggestions": [ + "临时网络问题,点击Rerun重试", + "如持续失败,检查Gitea服务状态" + ], + "auto_fixable": True, + }, +] + + +def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis: + """分析日志,返回诊断结果""" + + lines = log_text.strip().split("\n") + + # 收集所有匹配的模式 + matched = [] + error_lines = [] + + for line in lines: + line_stripped = line.strip() + # 收集ERROR/FAILED/Failed等错误行(最多20行) + if re.search(r"(ERROR|FAILED|Error|error:|FAIL:|Traceback)", line_stripped): + if len(error_lines) < 20: + error_lines.append(line_stripped) + + for pattern_info in FAILURE_PATTERNS: + if re.search(pattern_info["pattern"], line_stripped, re.IGNORECASE): + matched.append(pattern_info) + break # 一行只匹配一个模式 + + if not matched: + # 未识别的失败类型 + return FailureDiagnosis( + category="unknown", + category_cn="未知错误", + severity="medium", + summary="未识别的失败类型,需要人工查看日志", + error_lines=error_lines[:10], + suggestions=[ + "点击'查看失败日志'查看完整日志", + "如为偶发问题,可先重试一次", + "常见原因:环境问题、配置问题、新增逻辑引入的bug" + ], + auto_fixable=False, + ) + + # 选最严重、最具体的那个 + severity_order = {"high": 3, "medium": 2, "low": 1} + matched.sort(key=lambda x: severity_order.get(x["severity"], 0), reverse=True) + best_match = matched[0] + + # 生成摘要 + if "summary" in best_match: + summary = best_match["summary"] + else: + summary = f"{best_match['category_cn']}检查失败" + if job_name: + summary = f"[{job_name}] {summary}" + + # 从error_lines中过滤出与该分类相关的 + relevant_errors = error_lines[:10] + + return FailureDiagnosis( + category=best_match["category"], + category_cn=best_match["category_cn"], + severity=best_match["severity"], + summary=summary, + error_lines=relevant_errors, + suggestions=best_match["suggestions"], + auto_fixable=best_match.get("auto_fixable", False), + ) + + +def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Optional[str]: + """从Gitea API获取失败job的日志""" + api_base = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}" + + # 尝试获取job的日志 + url = f"{api_base}/actions/runs/{run_id}/jobs/{job_id}/log" + req = urllib.request.Request(url) + req.add_header("Authorization", f"token {token}") + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.read().decode("utf-8", errors="replace") + except Exception as e: + print(f"获取日志失败: {e}", file=sys.stderr) + return None + + +def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: str = "") -> str: + """将诊断结果格式化为飞书卡片markdown""" + + severity_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"} + emoji = severity_emoji.get(d.severity, "⚪") + + lines = [] + lines.append(f"**分类**: {emoji} {d.category_cn}") + lines.append(f"**问题**: {d.summary}") + + if d.error_lines: + lines.append("") + lines.append("**关键错误行**:") + for err in d.error_lines[:5]: + # 截断过长的行 + if len(err) > 150: + err = err[:147] + "..." + lines.append(f" `{err}`") + + lines.append("") + lines.append("**修复建议**:") + for i, s in enumerate(d.suggestions[:5], 1): + lines.append(f" {i}. {s}") + + if d.auto_fixable: + lines.append("") + lines.append("💡 **可自动修复**:如格式问题,可尝试点击Rerun让auto-fix自动处理") + + if run_url: + lines.append("") + lines.append(f"[查看完整日志]({run_url})") + + return "\n".join(lines) + + +def main(): + job_name = os.environ.get("FAILED_JOB", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas") + token = os.environ.get("GITHUB_TOKEN", "") + + # 1. 尝试获取日志 + log_text = "" + + # 优先从环境变量或文件读取 + log_file = os.environ.get("CI_LOG_FILE", "") + if log_file and os.path.exists(log_file): + with open(log_file) as f: + log_text = f.read() + elif run_id and token: + # 尝试从API获取(需要job_id,这里简化处理) + pass + + # 如果没有日志,用job_name做粗略分类 + if not log_text: + # 基于job名做初始判断 + if any(k in job_name.lower() for k in ["validate", "lint", "quality"]): + d = FailureDiagnosis( + category="lint_general", + category_cn="代码质量检查", + severity="low", + summary=f"{job_name} 检查失败(日志不可用,基于job名初步诊断)", + suggestions=[ + "点击查看日志获取具体错误信息", + "格式类问题通常可自动修复" + ], + auto_fixable=True, + ) + elif "build" in job_name.lower(): + d = FailureDiagnosis( + category="build_general", + category_cn="构建失败", + severity="high", + summary=f"{job_name} 构建失败(日志不可用)", + suggestions=[ + "点击查看日志获取具体构建错误", + "常见原因:Dockerfile错误、依赖安装失败、网络问题" + ], + auto_fixable=False, + ) + elif "test" in job_name.lower(): + d = FailureDiagnosis( + category="test_general", + category_cn="测试失败", + severity="high", + summary=f"{job_name} 测试失败(日志不可用)", + suggestions=[ + "点击查看日志获取具体失败的测试用例", + "检查最近代码改动是否影响了测试" + ], + auto_fixable=False, + ) + elif "deploy" in job_name.lower(): + d = FailureDiagnosis( + category="deploy_general", + category_cn="部署失败", + severity="high", + summary=f"{job_name} 部署失败(日志不可用)", + suggestions=[ + "检查目标服务器状态和网络", + "检查镜像是否正确推送", + "查看服务器上的容器日志" + ], + auto_fixable=False, + ) + else: + d = FailureDiagnosis( + category="unknown", + category_cn="未知错误", + severity="medium", + summary=f"{job_name} 失败", + suggestions=["点击查看日志获取详细信息"], + auto_fixable=False, + ) + else: + d = analyze_log(log_text, job_name) + + # 输出诊断结果 + run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id else "" + + print("=" * 60) + print(" CI 失败诊断报告") + print("=" * 60) + print() + print(format_diagnosis_markdown(d, job_name, run_url)) + print() + print("=" * 60) + + # 将诊断结果写入文件(供通知脚本读取) + output_file = os.environ.get("DIAGNOSIS_OUTPUT", "/tmp/ci_diagnosis.json") + result = { + "category": d.category, + "category_cn": d.category_cn, + "severity": d.severity, + "summary": d.summary, + "error_lines": d.error_lines, + "suggestions": d.suggestions, + "auto_fixable": d.auto_fixable, + } + with open(output_file, "w") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(f"\n诊断结果已保存到: {output_file}") + + +if __name__ == "__main__": + main() -- 2.54.0 From a18244b8a4e93e674bd46bf9c3dbc6db14633028 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 07:41:06 +0800 Subject: [PATCH 02/12] =?UTF-8?q?ci:=20=E5=A2=9E=E5=BC=BACI=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=80=9A=E7=9F=A5=EF=BC=8C=E8=87=AA=E5=8A=A8=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E5=B9=B6=E7=BB=99?= =?UTF-8?q?=E5=87=BA=E4=BF=AE=E5=A4=8D=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_notify_failure.py | 155 ++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 31 deletions(-) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index a8be3de03..d91770b8a 100755 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -1,17 +1,53 @@ #!/usr/bin/env python3 -"""发送 CI 失败通知到飞书/项目群 webhook。""" +"""发送 CI 失败通知到飞书/项目群 webhook(增强版:带失败诊断)。 + +诊断功能:自动分析失败原因,给出分类和修复建议。 +""" import json import os import sys import urllib.request +import subprocess + + +def run_diagnosis() -> dict: + """运行失败诊断脚本,返回诊断结果""" + diag_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ci/ci_failure_diagnosis.py") + if not os.path.exists(diag_script): + diag_script = "scripts/ci/ci_failure_diagnosis.py" + + result = {"category": "unknown", "category_cn": "未知", "severity": "medium", + "summary": "", "error_lines": [], "suggestions": [], "auto_fixable": False} + + try: + # 运行诊断脚本 + env = os.environ.copy() + env["DIAGNOSIS_OUTPUT"] = "/tmp/ci_diagnosis_result.json" + + proc = subprocess.run( + [sys.executable, diag_script], + capture_output=True, text=True, timeout=30, env=env + ) + + # 尝试读取结果文件 + output_file = "/tmp/ci_diagnosis_result.json" + if os.path.exists(output_file): + with open(output_file) as f: + result = json.load(f) + elif proc.stdout: + # 从stdout解析 + pass + except Exception as e: + print(f"诊断脚本执行失败: {e}", file=sys.stderr) + + return result def main() -> int: webhook = os.environ.get("CI_NOTIFY_WEBHOOK", "") if not webhook: print("未配置 CI_NOTIFY_WEBHOOK,跳过通知") - print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK") return 0 failed_job = os.environ.get("FAILED_JOB", "Unknown Job") @@ -21,6 +57,88 @@ def main() -> int: run_id = os.environ.get("GITHUB_RUN_ID", "unknown") repo = os.environ.get("GITHUB_REPOSITORY", "unknown") run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" + pr_number = os.environ.get("PR_NUMBER", "") + + # 运行诊断 + diagnosis = run_diagnosis() + + # 构建卡片内容 + severity_color = {"high": "red", "medium": "orange", "low": "blue"} + card_status = severity_color.get(diagnosis.get("severity", "medium"), "red") + + # 标题 + title = f"❌ CI失败 - {diagnosis.get('category_cn', '未知')}" + + # 诊断部分 + diag_lines = [] + diag_lines.append(f"**任务**: {failed_job}") + diag_lines.append(f"**分类**: {diagnosis.get('category_cn', '未知')}") + if diagnosis.get("summary"): + diag_lines.append(f"**问题**: {diagnosis['summary']}") + + # 错误行 + error_lines = diagnosis.get("error_lines", []) + if error_lines: + diag_lines.append("") + diag_lines.append("**关键错误**:") + for err in error_lines[:3]: + if len(err) > 100: + err = err[:97] + "..." + diag_lines.append(f"`{err}`") + + # 修复建议 + suggestions = diagnosis.get("suggestions", []) + if suggestions: + diag_lines.append("") + diag_lines.append("**修复建议**:") + for i, s in enumerate(suggestions[:3], 1): + diag_lines.append(f"{i}. {s}") + + if diagnosis.get("auto_fixable"): + diag_lines.append("") + diag_lines.append("💡 *可自动修复的问题,试试Rerun*") + + # 基本信息 + info_lines = [ + f"**分支**: {branch}", + f"**提交**: `{commit}`", + f"**提交者**: {actor}", + ] + if pr_number: + info_lines.append(f"**PR**: #{pr_number}") + + elements = [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": " +".join(diag_lines), + }, + }, + { + "tag": "hr", + }, + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": " +".join(info_lines), + }, + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": {"tag": "plain_text", "content": "查看失败日志"}, + "url": run_url, + "type": "danger", + }, + ], + }, + ] payload = { "msg_type": "interactive", @@ -28,36 +146,11 @@ def main() -> int: "header": { "title": { "tag": "plain_text", - "content": "❌ CI 构建失败", + "content": title, }, - "status": "red", + "status": card_status, }, - "elements": [ - { - "tag": "div", - "text": { - "tag": "lark_md", - "content": ( - f"**任务**: {failed_job}\n" - f"**分支**: {branch}\n" - f"**提交**: {commit}\n" - f"**提交者**: {actor}\n" - f"**Run ID**: {run_id}" - ), - }, - }, - { - "tag": "action", - "actions": [ - { - "tag": "button", - "text": {"tag": "plain_text", "content": "查看失败日志"}, - "url": run_url, - "type": "danger", - } - ], - }, - ], + "elements": elements, }, } @@ -71,7 +164,7 @@ def main() -> int: try: with urllib.request.urlopen(req, timeout=10) as resp: resp.read() - print("通知已发送") + print("通知已发送(带诊断信息)") except Exception as e: print(f"通知发送失败: {e}", file=sys.stderr) return 1 -- 2.54.0 From f22a6fd90ef9089bd373a66526564ae7500d7717 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 22 Jul 2026 07:44:08 +0800 Subject: [PATCH 03/12] style: auto-format with black + isort + prettier [ci skip] --- scripts/ci/ci_failure_diagnosis.py | 120 +++++++++++------------------ scripts/ci_notify_failure.py | 2 +- 2 files changed, 47 insertions(+), 75 deletions(-) diff --git a/scripts/ci/ci_failure_diagnosis.py b/scripts/ci/ci_failure_diagnosis.py index 570e6f8c1..535243464 100644 --- a/scripts/ci/ci_failure_diagnosis.py +++ b/scripts/ci/ci_failure_diagnosis.py @@ -14,7 +14,7 @@ 用法: python3 scripts/ci/ci_failure_diagnosis.py [--job-name "Job Name"] [--log-file /path/to/log] - + 如果不传--log-file,会尝试从Gitea API获取失败job的日志。 """ @@ -30,6 +30,7 @@ from typing import List, Optional @dataclass class FailureDiagnosis: """失败诊断结果""" + category: str # 失败分类 category_cn: str # 中文分类名 severity: str # 严重程度: high / medium / low @@ -55,7 +56,7 @@ FAILURE_PATTERNS = [ "suggestions": [ "本地运行 `black . && isort . && ruff check --fix .` 自动修复", "使用 `scripts/agent-commit.sh` 提交(自动格式化)", - "如确认无误,可加 `# noqa: xxx` 忽略特定规则" + "如确认无误,可加 `# noqa: xxx` 忽略特定规则", ], "auto_fixable": True, }, @@ -67,7 +68,7 @@ FAILURE_PATTERNS = [ "summary_contains": ["eslint", "prettier"], "suggestions": [ "本地运行 `cd apps/web && npm run lint:fix` 自动修复", - "Prettier问题: `cd apps/web && npx prettier --write .`" + "Prettier问题: `cd apps/web && npx prettier --write .`", ], "auto_fixable": True, }, @@ -81,11 +82,10 @@ FAILURE_PATTERNS = [ "F841: 删除未使用的变量或加下划线前缀", "E501: 行超长,加 `# noqa: E501`", "F811: 删重复import", - "运行 `ruff check --fix .` 自动修复大部分问题" + "运行 `ruff check --fix .` 自动修复大部分问题", ], "auto_fixable": True, }, - # ===== 单元测试失败 ===== { "pattern": r"FAILED|assert.*Error|AssertionError", @@ -95,7 +95,7 @@ FAILURE_PATTERNS = [ "suggestions": [ "检查相关测试文件,确认是代码问题还是测试用例问题", "本地运行对应测试:`pytest path/to/test.py -v`", - "如测试依赖外部服务,检查mock是否正确" + "如测试依赖外部服务,检查mock是否正确", ], "auto_fixable": False, }, @@ -107,11 +107,10 @@ FAILURE_PATTERNS = [ "suggestions": [ "查看上方日志中的FAILED测试用例", "检查失败断言的期望值 vs 实际值", - "新代码影响了现有测试行为,确认是预期内变更吗?" + "新代码影响了现有测试行为,确认是预期内变更吗?", ], "auto_fixable": False, }, - # ===== Docker 构建失败 ===== { "pattern": r"Dockerfile.*not found|docker build.*failed|ERROR: failed to solve", @@ -121,7 +120,7 @@ FAILURE_PATTERNS = [ "suggestions": [ "检查Dockerfile语法是否正确", "检查引用的基础镜像是否存在", - "本地运行 `docker build -f path/to/Dockerfile .` 复现" + "本地运行 `docker build -f path/to/Dockerfile .` 复现", ], "auto_fixable": False, }, @@ -133,7 +132,7 @@ FAILURE_PATTERNS = [ "suggestions": [ "检查基础镜像名称和tag是否正确", "确认镜像仓库可访问,登录是否有效", - "如为新基础镜像,需先手动构建一次基础镜像" + "如为新基础镜像,需先手动构建一次基础镜像", ], "auto_fixable": False, }, @@ -143,13 +142,9 @@ FAILURE_PATTERNS = [ "category_cn": "文件锁冲突(ETXTBSY)", "severity": "low", "summary": "esbuild并发构建冲突,重试即可", - "suggestions": [ - "偶发问题,点击Rerun重新运行即可", - "如频繁出现,检查是否有多个job并发写入同一文件" - ], + "suggestions": ["偶发问题,点击Rerun重新运行即可", "如频繁出现,检查是否有多个job并发写入同一文件"], "auto_fixable": True, }, - # ===== 依赖安装失败 ===== { "pattern": r"pip install.*error|Could not find a version|No matching distribution", @@ -159,7 +154,7 @@ FAILURE_PATTERNS = [ "suggestions": [ "检查requirements.txt中的版本号是否正确", "如为新版本刚发布,可能源还没同步,稍后重试", - "检查网络连接,可尝试切换pip镜像源" + "检查网络连接,可尝试切换pip镜像源", ], "auto_fixable": False, }, @@ -171,7 +166,7 @@ FAILURE_PATTERNS = [ "suggestions": [ "检查package.json中的版本号是否存在", "网络问题:检查npm registry是否可访问", - "国内网络建议配置npmmirror镜像源" + "国内网络建议配置npmmirror镜像源", ], "auto_fixable": False, }, @@ -184,11 +179,10 @@ FAILURE_PATTERNS = [ "suggestions": [ "点击Rerun重试,网络问题通常是临时的", "如持续失败,检查对应服务是否正常", - "检查Runner网络配置" + "检查Runner网络配置", ], "auto_fixable": True, }, - # ===== 超时 ===== { "pattern": r"timeout|timed out|exceeded.*time limit|job.*cancelled.*timeout", @@ -198,11 +192,10 @@ FAILURE_PATTERNS = [ "suggestions": [ "如首次出现:重试一次,可能是临时性能波动", "频繁出现:检查构建是否变慢了,最近是否加了新依赖", - "可适当增加timeout-minutes配置" + "可适当增加timeout-minutes配置", ], "auto_fixable": False, }, - # ===== 数据库/迁移 ===== { "pattern": r"alembic.*error|migration.*failed|relation.*does not exist|column.*does not exist", @@ -212,34 +205,26 @@ FAILURE_PATTERNS = [ "suggestions": [ "检查迁移脚本是否正确,down_revision是否对", "确认数据库中是否有脏数据或残留表", - "迁移脚本合并冲突时,重新生成迁移文件" + "迁移脚本合并冲突时,重新生成迁移文件", ], "auto_fixable": False, }, - # ===== 缓存问题 ===== { "pattern": r"cache.*corrupt|cache.*invalid|snapshot.*not found|failed to compute cache key", "category": "cache", "category_cn": "缓存损坏", "severity": "low", - "suggestions": [ - "构建系统会自动清理损坏缓存并重试,通常无需干预", - "如持续失败,手动清理Runner上的缓存目录" - ], + "suggestions": ["构建系统会自动清理损坏缓存并重试,通常无需干预", "如持续失败,手动清理Runner上的缓存目录"], "auto_fixable": True, }, - # ===== Checkout 失败 ===== { "pattern": r"Could not resolve host|fatal:.*repository|SSL.*problem", "category": "checkout", "category_cn": "代码拉取失败", "severity": "low", - "suggestions": [ - "临时网络问题,点击Rerun重试", - "如持续失败,检查Gitea服务状态" - ], + "suggestions": ["临时网络问题,点击Rerun重试", "如持续失败,检查Gitea服务状态"], "auto_fixable": True, }, ] @@ -247,25 +232,25 @@ FAILURE_PATTERNS = [ def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis: """分析日志,返回诊断结果""" - + lines = log_text.strip().split("\n") - + # 收集所有匹配的模式 matched = [] error_lines = [] - + for line in lines: line_stripped = line.strip() # 收集ERROR/FAILED/Failed等错误行(最多20行) if re.search(r"(ERROR|FAILED|Error|error:|FAIL:|Traceback)", line_stripped): if len(error_lines) < 20: error_lines.append(line_stripped) - + for pattern_info in FAILURE_PATTERNS: if re.search(pattern_info["pattern"], line_stripped, re.IGNORECASE): matched.append(pattern_info) break # 一行只匹配一个模式 - + if not matched: # 未识别的失败类型 return FailureDiagnosis( @@ -277,16 +262,16 @@ def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis: suggestions=[ "点击'查看失败日志'查看完整日志", "如为偶发问题,可先重试一次", - "常见原因:环境问题、配置问题、新增逻辑引入的bug" + "常见原因:环境问题、配置问题、新增逻辑引入的bug", ], auto_fixable=False, ) - + # 选最严重、最具体的那个 severity_order = {"high": 3, "medium": 2, "low": 1} matched.sort(key=lambda x: severity_order.get(x["severity"], 0), reverse=True) best_match = matched[0] - + # 生成摘要 if "summary" in best_match: summary = best_match["summary"] @@ -294,10 +279,10 @@ def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis: summary = f"{best_match['category_cn']}检查失败" if job_name: summary = f"[{job_name}] {summary}" - + # 从error_lines中过滤出与该分类相关的 relevant_errors = error_lines[:10] - + return FailureDiagnosis( category=best_match["category"], category_cn=best_match["category_cn"], @@ -312,12 +297,12 @@ def analyze_log(log_text: str, job_name: str = "") -> FailureDiagnosis: def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Optional[str]: """从Gitea API获取失败job的日志""" api_base = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}" - + # 尝试获取job的日志 url = f"{api_base}/actions/runs/{run_id}/jobs/{job_id}/log" req = urllib.request.Request(url) req.add_header("Authorization", f"token {token}") - + try: with urllib.request.urlopen(req, timeout=15) as resp: return resp.read().decode("utf-8", errors="replace") @@ -328,14 +313,14 @@ def fetch_failed_job_log(run_id: str, job_id: str, token: str, repo: str) -> Opt def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: str = "") -> str: """将诊断结果格式化为飞书卡片markdown""" - + severity_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"} emoji = severity_emoji.get(d.severity, "⚪") - + lines = [] lines.append(f"**分类**: {emoji} {d.category_cn}") lines.append(f"**问题**: {d.summary}") - + if d.error_lines: lines.append("") lines.append("**关键错误行**:") @@ -344,20 +329,20 @@ def format_diagnosis_markdown(d: FailureDiagnosis, job_name: str = "", run_url: if len(err) > 150: err = err[:147] + "..." lines.append(f" `{err}`") - + lines.append("") lines.append("**修复建议**:") for i, s in enumerate(d.suggestions[:5], 1): lines.append(f" {i}. {s}") - + if d.auto_fixable: lines.append("") lines.append("💡 **可自动修复**:如格式问题,可尝试点击Rerun让auto-fix自动处理") - + if run_url: lines.append("") lines.append(f"[查看完整日志]({run_url})") - + return "\n".join(lines) @@ -366,10 +351,10 @@ def main(): run_id = os.environ.get("GITHUB_RUN_ID", "") repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas") token = os.environ.get("GITHUB_TOKEN", "") - + # 1. 尝试获取日志 log_text = "" - + # 优先从环境变量或文件读取 log_file = os.environ.get("CI_LOG_FILE", "") if log_file and os.path.exists(log_file): @@ -378,7 +363,7 @@ def main(): elif run_id and token: # 尝试从API获取(需要job_id,这里简化处理) pass - + # 如果没有日志,用job_name做粗略分类 if not log_text: # 基于job名做初始判断 @@ -388,10 +373,7 @@ def main(): category_cn="代码质量检查", severity="low", summary=f"{job_name} 检查失败(日志不可用,基于job名初步诊断)", - suggestions=[ - "点击查看日志获取具体错误信息", - "格式类问题通常可自动修复" - ], + suggestions=["点击查看日志获取具体错误信息", "格式类问题通常可自动修复"], auto_fixable=True, ) elif "build" in job_name.lower(): @@ -400,10 +382,7 @@ def main(): category_cn="构建失败", severity="high", summary=f"{job_name} 构建失败(日志不可用)", - suggestions=[ - "点击查看日志获取具体构建错误", - "常见原因:Dockerfile错误、依赖安装失败、网络问题" - ], + suggestions=["点击查看日志获取具体构建错误", "常见原因:Dockerfile错误、依赖安装失败、网络问题"], auto_fixable=False, ) elif "test" in job_name.lower(): @@ -412,10 +391,7 @@ def main(): category_cn="测试失败", severity="high", summary=f"{job_name} 测试失败(日志不可用)", - suggestions=[ - "点击查看日志获取具体失败的测试用例", - "检查最近代码改动是否影响了测试" - ], + suggestions=["点击查看日志获取具体失败的测试用例", "检查最近代码改动是否影响了测试"], auto_fixable=False, ) elif "deploy" in job_name.lower(): @@ -424,11 +400,7 @@ def main(): category_cn="部署失败", severity="high", summary=f"{job_name} 部署失败(日志不可用)", - suggestions=[ - "检查目标服务器状态和网络", - "检查镜像是否正确推送", - "查看服务器上的容器日志" - ], + suggestions=["检查目标服务器状态和网络", "检查镜像是否正确推送", "查看服务器上的容器日志"], auto_fixable=False, ) else: @@ -442,10 +414,10 @@ def main(): ) else: d = analyze_log(log_text, job_name) - + # 输出诊断结果 run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}" if run_id else "" - + print("=" * 60) print(" CI 失败诊断报告") print("=" * 60) @@ -453,7 +425,7 @@ def main(): print(format_diagnosis_markdown(d, job_name, run_url)) print() print("=" * 60) - + # 将诊断结果写入文件(供通知脚本读取) output_file = os.environ.get("DIAGNOSIS_OUTPUT", "/tmp/ci_diagnosis.json") result = { diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index d91770b8a..0ca0e931d 100755 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -6,9 +6,9 @@ import json import os +import subprocess import sys import urllib.request -import subprocess def run_diagnosis() -> dict: -- 2.54.0 From 81ae0899856813d2606f58d15168186977d74dad Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 08:46:12 +0800 Subject: [PATCH 04/12] chore: trigger CI run after auto-format fix --- scripts/ci/ci_failure_diagnosis.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/ci_failure_diagnosis.py b/scripts/ci/ci_failure_diagnosis.py index 535243464..5244484b2 100644 --- a/scripts/ci/ci_failure_diagnosis.py +++ b/scripts/ci/ci_failure_diagnosis.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """CI失败诊断增强脚本:自动分类失败原因 + 提取关键错误 + 给出修复建议。 +# Trigger CI after auto-format fix 支持的失败类型: 1. Lint/格式问题 (ruff/black/eslint/prettier) -- 2.54.0 From 05b84ac669ea82f838525f9d5f755965b82429b2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 09:03:05 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dci=5Fnotify=5Ffa?= =?UTF-8?q?ilure.py=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF=EF=BC=8C=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=E5=AD=97=E7=AC=A6=E4=B8=B2=E8=A2=AB=E6=8B=86=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci_notify_failure.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index 0ca0e931d..ffe13b47c 100755 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -112,8 +112,7 @@ def main() -> int: "tag": "div", "text": { "tag": "lark_md", - "content": " -".join(diag_lines), + "content": "\n".join(diag_lines), }, }, { @@ -123,8 +122,7 @@ def main() -> int: "tag": "div", "text": { "tag": "lark_md", - "content": " -".join(info_lines), + "content": "\n".join(info_lines), }, }, { -- 2.54.0 From 24efd5121fba45b3b30bed7cc5c40ae3c8617692 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Wed, 22 Jul 2026 09:10:39 +0800 Subject: [PATCH 06/12] style: auto-format with black + isort + prettier [ci skip] --- scripts/ci_notify_failure.py | 40 ++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index ffe13b47c..7310a0f92 100755 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -16,20 +16,24 @@ def run_diagnosis() -> dict: diag_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ci/ci_failure_diagnosis.py") if not os.path.exists(diag_script): diag_script = "scripts/ci/ci_failure_diagnosis.py" - - result = {"category": "unknown", "category_cn": "未知", "severity": "medium", - "summary": "", "error_lines": [], "suggestions": [], "auto_fixable": False} - + + result = { + "category": "unknown", + "category_cn": "未知", + "severity": "medium", + "summary": "", + "error_lines": [], + "suggestions": [], + "auto_fixable": False, + } + try: # 运行诊断脚本 env = os.environ.copy() env["DIAGNOSIS_OUTPUT"] = "/tmp/ci_diagnosis_result.json" - - proc = subprocess.run( - [sys.executable, diag_script], - capture_output=True, text=True, timeout=30, env=env - ) - + + proc = subprocess.run([sys.executable, diag_script], capture_output=True, text=True, timeout=30, env=env) + # 尝试读取结果文件 output_file = "/tmp/ci_diagnosis_result.json" if os.path.exists(output_file): @@ -40,7 +44,7 @@ def run_diagnosis() -> dict: pass except Exception as e: print(f"诊断脚本执行失败: {e}", file=sys.stderr) - + return result @@ -61,21 +65,21 @@ def main() -> int: # 运行诊断 diagnosis = run_diagnosis() - + # 构建卡片内容 severity_color = {"high": "red", "medium": "orange", "low": "blue"} card_status = severity_color.get(diagnosis.get("severity", "medium"), "red") - + # 标题 title = f"❌ CI失败 - {diagnosis.get('category_cn', '未知')}" - + # 诊断部分 diag_lines = [] diag_lines.append(f"**任务**: {failed_job}") diag_lines.append(f"**分类**: {diagnosis.get('category_cn', '未知')}") if diagnosis.get("summary"): diag_lines.append(f"**问题**: {diagnosis['summary']}") - + # 错误行 error_lines = diagnosis.get("error_lines", []) if error_lines: @@ -85,7 +89,7 @@ def main() -> int: if len(err) > 100: err = err[:97] + "..." diag_lines.append(f"`{err}`") - + # 修复建议 suggestions = diagnosis.get("suggestions", []) if suggestions: @@ -93,7 +97,7 @@ def main() -> int: diag_lines.append("**修复建议**:") for i, s in enumerate(suggestions[:3], 1): diag_lines.append(f"{i}. {s}") - + if diagnosis.get("auto_fixable"): diag_lines.append("") diag_lines.append("💡 *可自动修复的问题,试试Rerun*") @@ -106,7 +110,7 @@ def main() -> int: ] if pr_number: info_lines.append(f"**PR**: #{pr_number}") - + elements = [ { "tag": "div", -- 2.54.0 From 0ae105628d51ebd89e51fa8e1c7446a7edbd60be Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 09:14:31 +0800 Subject: [PATCH 07/12] chore: trigger CI (bypass auto-fix [ci skip] bug) --- scripts/ci_notify_failure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ci_notify_failure.py b/scripts/ci_notify_failure.py index 7310a0f92..d7b620c8d 100755 --- a/scripts/ci_notify_failure.py +++ b/scripts/ci_notify_failure.py @@ -176,3 +176,5 @@ def main() -> int: if __name__ == "__main__": sys.exit(main()) + +# trigger CI - bypass [ci skip] bug -- 2.54.0 From cfe75543a19b015be184429eca59f18f8553ee4f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 09:27:51 +0800 Subject: [PATCH 08/12] chore: sync ci-pipeline and validate scripts from develop --- .gitea/workflows/ci-pipeline.yml | 292 ++++++++++++++++++++++++++++++- 1 file changed, 290 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci-pipeline.yml b/.gitea/workflows/ci-pipeline.yml index 64c9551ea..2245d06bf 100755 --- a/.gitea/workflows/ci-pipeline.yml +++ b/.gitea/workflows/ci-pipeline.yml @@ -172,6 +172,250 @@ jobs: [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + validate-code-quality: + name: Validate - Code Quality + runs-on: ci-l2 + timeout-minutes: 8 + permissions: + contents: write + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install dependencies + shell: sh + run: | + set -eu + for i in 1 2 3; do + python3 -m pip install -q -r requirements-base.txt && break + echo "pip install requirements-base.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements.txt && break + echo "pip install requirements.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements-dev.txt && break + echo "pip install requirements-dev.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 && break + echo "pip install black/isort 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + - name: Run code quality and security checks + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash scripts/ci/validate_code_quality.sh + - name: Auto-fix formatting (black + isort) + if: failure() + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/ci/auto_fix_formatting.py + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: | + set +e + FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ ${{ job.status }} = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + validate-type-check: + name: Validate - Type Check (mypy) + runs-on: ci-l2 + timeout-minutes: 8 + permissions: + contents: read + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install dependencies + shell: sh + run: | + set -eu + for i in 1 2 3; do + python3 -m pip install -q -r requirements-base.txt && break + echo "pip install requirements-base.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements.txt && break + echo "pip install requirements.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements-dev.txt && break + echo "pip install requirements-dev.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + - name: Run mypy type check + shell: bash + run: bash scripts/ci/validate_mypy.sh + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: | + set +e + FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ ${{ job.status }} = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + + validate-migration: + name: Validate - Migration (alembic) + runs-on: ci-l2 + timeout-minutes: 8 + permissions: + contents: read + env: + DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas + USE_IN_MEMORY_DB: 'false' + CI_USE_SHARED_PG: 'true' + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash + - name: Record job start time + shell: sh + run: bash scripts/ci/step_timer_start.sh + - name: Install dependencies + shell: sh + run: | + set -eu + for i in 1 2 3; do + python3 -m pip install -q -r requirements-base.txt && break + echo "pip install requirements-base.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements.txt && break + echo "pip install requirements.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + for i in 1 2 3; do + python3 -m pip install -q -r requirements-dev.txt && break + echo "pip install requirements-dev.txt 失败,重试 $i/3..." + [ $i -eq 3 ] && exit 1 + sleep 5 + done + - name: Run alembic migration validation + shell: bash + run: bash scripts/ci/validate_migration.sh + - name: CI failure notification + if: failure() + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }} + run: | + set +e + FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py + - name: Job duration summary + if: always() + shell: sh + run: bash scripts/ci/step_timer_end.sh + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="Validate - Migration (alembic)" python3 scripts/ci_notify.py + - name: Report CI trace + if: always() + shell: sh + env: + AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }} + run: | + STATUS="ok" + [ ${{ job.status }} = "success" ] || STATUS="error" + START_TIME="" + [ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time) + python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true + unit-tests: needs: check-frontend-only if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' @@ -387,9 +631,11 @@ jobs: [ $i -eq 3 ] && exit 1 sleep 5 done - - name: Run Vitest with coverage + - name: Run Vitest (incremental for PRs, full for main branches) shell: sh - run: bash scripts/ci/step_frontend_run.sh "npx --no-install vitest run --coverage" + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash scripts/ci/vitest_incremental.sh - name: Job duration summary if: always() shell: sh @@ -472,6 +718,36 @@ jobs: echo "Docker login failed ($i/3), retrying in 5s..." sleep 5 done + - name: Pre-build worker base images (fallback if not exist) + if: matrix.service == 'worker' + id: prebuild + shell: sh + run: | + set -eu + REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas" + BASE_BUILDER="${REGISTRY}/worker-base-builder:latest" + BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest" + + # 尝试拉取基础镜像 + echo "检查基础镜像..." + if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then + echo "基础镜像已存在,使用远程镜像" + echo "fallback=false" >> $GITHUB_OUTPUT + else + echo "基础镜像不存在,本地构建(fallback模式)..." + + # 构建builder基础镜像 + echo "构建 worker-base-builder..." + docker build -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" . + + # 构建runtime基础镜像 + echo "构建 worker-base-runtime..." + docker build -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" . + + echo "fallback=true" >> $GITHUB_OUTPUT + echo "基础镜像本地构建完成" + fi + - name: Build PR image (verify only, no push) shell: sh run: | @@ -485,6 +761,18 @@ jobs: EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf" fi + # Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx + if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then + echo "Fallback模式:用普通docker build(基础镜像本地已构建)" + BUILD_ARG_STR="" + for arg in $EXTRA_BUILD_ARGS; do + BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg" + done + docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR . + echo "Fallback PR Build successful" + exit 0 + fi + NO_CACHE_FLAG="" for i in 1 2 3; do echo "PR Build attempt $i/3" -- 2.54.0 From 3379f07a1504516dc213ed1b61fea439c3238133 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 09:27:52 +0800 Subject: [PATCH 09/12] chore: sync ci-pipeline and validate scripts from develop --- scripts/ci/validate_code_quality.sh | 186 ++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 scripts/ci/validate_code_quality.sh diff --git a/scripts/ci/validate_code_quality.sh b/scripts/ci/validate_code_quality.sh new file mode 100644 index 000000000..22b83f294 --- /dev/null +++ b/scripts/ci/validate_code_quality.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# CI Validate: 代码质量与安全扫描(并行Job 1/3) +# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验 +set -eu + +echo "=== CI Validate: 代码质量与安全扫描 ===" + +# --- 密钥检测 --- +echo "" +echo "=== [1/6] Secret detection (detect-secrets) ===" +python3 -m pip install -q detect-secrets +detect-secrets --version + +detect-secrets scan \ + --all-files \ + --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \ + --exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \ + --exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \ + --disable-plugin Base64HighEntropyString \ + --disable-plugin HexHighEntropyString \ + --disable-plugin BasicAuthDetector \ + --disable-plugin KeywordDetector \ + --disable-plugin IPPublicDetector \ + > /tmp/secrets-scan.json 2>&1 + +FOUND=$(python3 -c " +import json +try: + with open('/tmp/secrets-scan.json') as f: + data = json.load(f) + results = data.get('results', {}) + total = sum(len(v) for v in results.values()) + print(total) +except Exception: + print('error') +") + +echo "Secrets detected: $FOUND" +if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then + echo "" + echo "=== Secret details ===" + python3 -c " +import json +with open('/tmp/secrets-scan.json') as f: + data = json.load(f) +for fpath, items in data.get('results', {}).items(): + for item in items: + line = item.get('line_number', '?') + stype = item.get('type', '?') + hashed = item.get('hashed_secret', '')[:16] + print(f' {fpath}:{line} [{stype}] {hashed}...') +" + echo "" + echo "ERROR: Potential secrets detected in code!" + exit 1 +fi +echo "✅ Secret scan passed" + +# --- 增量/全量模式判断 --- +echo "" +echo "=== [2/6] Code quality checks ===" +SCAN_MODE="full" +CHANGED_PY_FILES="" + +if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100" + set +e + RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL") + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + set -e + if [ "$HTTP_CODE" = "200" ]; then + CHANGED_PY_FILES=$(echo "$BODY" | python3 -c " +import json, sys +try: + files = json.load(sys.stdin) + py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed'] + print(' '.join(py_files)) +except Exception: + print('') +") + if [ -n "$CHANGED_PY_FILES" ]; then + SCAN_MODE="incremental" + echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed" + else + SCAN_MODE="skip_py" + echo "No Python files changed in this PR" + fi + else + echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan" + fi +else + echo "Full scan mode (not a PR event)" +fi + +if [ "$SCAN_MODE" = "incremental" ]; then + # 防御性过滤 + EXISTING_PY_FILES="" + for f in $CHANGED_PY_FILES; do + if [ -f "$f" ]; then + if [ -z "$EXISTING_PY_FILES" ]; then + EXISTING_PY_FILES="$f" + else + EXISTING_PY_FILES="$EXISTING_PY_FILES $f" + fi + fi + done + CHANGED_PY_FILES="$EXISTING_PY_FILES" + + python3 -m compileall -q $CHANGED_PY_FILES + python3 -m black --check --fast $CHANGED_PY_FILES + python3 -m isort --check-only $CHANGED_PY_FILES + RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs) + if [ -n "$RUFF_FILES" ]; then + python3 -m ruff check $RUFF_FILES --statistics + else + echo "No ruff-checkable files changed, skipping" + fi +elif [ "$SCAN_MODE" = "skip_py" ]; then + echo "No Python files changed - skipping Python lint checks" +else + echo "Full scan mode" + python3 -m compileall -q alembic apps packages tests scripts + python3 -m black --check --fast alembic apps packages tests scripts + python3 -m isort --check-only alembic apps packages tests scripts + python3 -m ruff check apps packages tests --statistics +fi +echo "✅ Code quality checks passed" + +# --- Bandit 安全扫描(仅告警) --- +echo "" +echo "=== [3/6] Security scan (bandit, advisory only) ===" +set +e +bandit -r apps packages -q -ll +BANDIT_EXIT=$? +set -e +if [ "$BANDIT_EXIT" -ne 0 ]; then + echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)" +else + echo "✅ Bandit security scan passed" +fi + +# --- Pip-audit 依赖漏洞扫描(仅告警) --- +echo "" +echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ===" +python3 -m pip install -q pip-audit +pip-audit --version +EXIT_CODE=0 +for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do + if [ -f "$req_file" ]; then + echo "--- Scanning $req_file ---" + pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$? + echo "" + fi +done +echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)" + +# --- Vulture 死代码检测(仅告警) --- +echo "" +echo "=== [5/6] Dead code detection (vulture, advisory only) ===" +set +e +python3 -m pip install -q vulture +vulture --version +echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。" +echo "" +vulture apps packages scripts \ + --exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \ + --min-confidence 70 \ + 2>&1 | sort -t'(' -k2 -rn | head -80 +echo "" +echo "=== vulture scan summary ===" +echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)" +echo "建议:定期人工审查高置信度(>=90%)条目" +set -e + +# --- Release 脚本语法校验 --- +echo "" +echo "=== [6/6] Release scripts syntax validation ===" +bash -n scripts/backup_postgres.sh +bash -n scripts/restore_postgres_plan.sh +bash -n scripts/init_production_env.sh +echo "✅ Release scripts syntax OK" + +echo "" +echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ===" -- 2.54.0 From 92c11ced634f9c2f6cf9cc377cab9b41787bffcf Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 09:27:53 +0800 Subject: [PATCH 10/12] chore: sync ci-pipeline and validate scripts from develop --- scripts/ci/validate_mypy.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 scripts/ci/validate_mypy.sh diff --git a/scripts/ci/validate_mypy.sh b/scripts/ci/validate_mypy.sh new file mode 100644 index 000000000..645776828 --- /dev/null +++ b/scripts/ci/validate_mypy.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# CI Validate: Mypy类型检查(并行Job 2/3) +set -eu + +echo "=== CI Validate: Mypy类型检查 ===" + +bash scripts/ci/mypy_check.sh + +echo "" +echo "=== CI Validate: Mypy类型检查 通过 ✅ ===" -- 2.54.0 From 14be2b654bf195a406640a91cbfe4669f0a433fe Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 09:27:54 +0800 Subject: [PATCH 11/12] chore: sync ci-pipeline and validate scripts from develop --- scripts/ci/validate_migration.sh | 182 +++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 scripts/ci/validate_migration.sh diff --git a/scripts/ci/validate_migration.sh b/scripts/ci/validate_migration.sh new file mode 100644 index 000000000..998dc1651 --- /dev/null +++ b/scripts/ci/validate_migration.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# CI Validate: Alembic迁移验证(并行Job 3/3) +# 需要PostgreSQL数据库 +set -eu + +echo "=== CI Validate: Alembic迁移验证 ===" + +# --- DooD模式检测:确定宿主机访问地址 --- +detect_docker_host() { + local test_port="${1:-5432}" + + local candidates=() + + # 1. host.docker.internal + if python3 -c "import socket; socket.gethostbyname('host.docker.internal')" 2>/dev/null; then + candidates+=("host.docker.internal") + fi + + # 2. docker0 桥接网关 + candidates+=("172.17.0.1") + + # 3. 默认网关 + local gw="" + gw=$(ip route 2>/dev/null | grep default | awk '{print $3}' | head -1) + if [ -n "$gw" ] && [ "$gw" != "127.0.0.1" ]; then + candidates+=("$gw") + fi + + # 4. 宿主机同网段的.1或.254 + local my_ip="" + my_ip=$(hostname -I 2>/dev/null | awk '{print $1}') + if [ -n "$my_ip" ]; then + local subnet=$(echo "$my_ip" | cut -d. -f1-3) + candidates+=("${subnet}.1") + candidates+=("${subnet}.254") + fi + + # 5. 127.0.0.1 最后尝试 + candidates+=("127.0.0.1") + + for candidate in "${candidates[@]}"; do + if python3 -c " +import socket +s = socket.socket() +s.settimeout(2) +try: + s.connect(('$candidate', $test_port)) + s.close() + print('ok') +except: + pass +" 2>/dev/null | grep -q ok; then + echo "$candidate" + return 0 + fi + done + + echo "127.0.0.1" + return 1 +} + +# 获取宿主机IP +if [ -S /var/run/docker.sock ]; then + DOCKER_HOST_IP=$(detect_docker_host 5433) + if [ "$DOCKER_HOST_IP" = "127.0.0.1" ]; then + DOCKER_HOST_IP=$(detect_docker_host 22) + fi + echo "检测到DooD模式,宿主机地址: $DOCKER_HOST_IP" +else + DOCKER_HOST_IP="127.0.0.1" + echo "非DooD模式,使用 127.0.0.1" +fi +PG_HOST="$DOCKER_HOST_IP" +echo "PG host: $PG_HOST" + +# 指数退避TCP连接检查 +wait_tcp_ready() { + local host="$1" + local port="$2" + local max_attempts="${3:-5}" + local delay=1 + local attempt=1 + while [ "$attempt" -le "$max_attempts" ]; do + if python3 -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('$host', $port)); s.close()" 2>/dev/null; then + return 0 + fi + echo "TCP连接尝试 $attempt/$max_attempts 失败,${delay}s后重试..." + sleep "$delay" + delay=$((delay * 2)) + attempt=$((attempt + 1)) + done + return 1 +} + +USE_SHARED_PG="${CI_USE_SHARED_PG:-false}" + +if [ "$USE_SHARED_PG" = "true" ]; then + # 使用常驻共享PG实例 + echo "使用常驻共享PG实例(CI_USE_SHARED_PG=true)" + SHARED_PG_HOST="$PG_HOST" + SHARED_PG_PORT="5433" + SHARED_PG_USER="postgres" + SHARED_PG_PASSWORD="ci_pg_2026!" + CI_DB_NAME="ci_run_${GITHUB_RUN_ID:-$$}" + + echo "等待共享PG连接就绪..." + wait_tcp_ready "$SHARED_PG_HOST" "$SHARED_PG_PORT" 5 + + echo "创建测试数据库: $CI_DB_NAME" + PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c " +import psycopg2 +conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres') +conn.autocommit = True +cur = conn.cursor() +cur.execute(f'CREATE DATABASE \"$CI_DB_NAME\"') +cur.close() +conn.close() +" + export DATABASE_URL="postgresql+psycopg://${SHARED_PG_USER}:${SHARED_PG_PASSWORD}@${SHARED_PG_HOST}:${SHARED_PG_PORT}/${CI_DB_NAME}" + echo "✅ 共享PG数据库已创建: $CI_DB_NAME" + + # 执行迁移 + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + echo "✅ Alembic migrations applied successfully" + + # 清理数据库 + echo "清理测试数据库: $CI_DB_NAME" + PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c " +import psycopg2 +conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres') +conn.autocommit = True +cur = conn.cursor() +cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)') +cur.close() +conn.close() +" 2>/dev/null || echo "WARN: 数据库清理失败" + echo "✅ 共享PG数据库已清理" +else + # 使用临时PG容器(默认模式) + echo "使用临时PG容器模式" + PG_CONTAINER=ci-pg-validate-migration-${GITHUB_RUN_ID:-$$} + docker rm -f "$PG_CONTAINER" 2>/dev/null || true + docker run -d --name "$PG_CONTAINER" \ + --shm-size=256m \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=xiaoxia_saas \ + -P \ + --health-cmd "pg_isready -U postgres" \ + --health-interval 3s \ + --health-timeout 3s \ + --health-retries 20 \ + postgres:16-alpine + PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2) + echo "PostgreSQL port: $PG_PORT" + export DATABASE_URL="postgresql+psycopg://postgres:postgres@${PG_HOST}:${PG_PORT}/xiaoxia_saas" + + # 等待容器健康 + for i in $(seq 1 30); do + if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then + echo "PostgreSQL container is healthy on port $PG_PORT" + break + fi + echo "Waiting for PostgreSQL container health... ($i/30)" + sleep 2 + done + docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy + + # TCP连通性检查 + echo "验证TCP连通性 ($PG_HOST:$PG_PORT)..." + wait_tcp_ready "$PG_HOST" "$PG_PORT" 5 + echo "TCP connectivity to PostgreSQL confirmed on port $PG_PORT" + + # 执行迁移 + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m alembic upgrade head + echo "✅ Alembic migrations applied successfully" + + docker rm -f "$PG_CONTAINER" 2>/dev/null || true +fi + +echo "" +echo "=== CI Validate: Alembic迁移验证 通过 ✅ ===" -- 2.54.0 From 6ce7db7f8e607332eabce7b9b22e75e8542dab70 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 22 Jul 2026 10:28:13 +0800 Subject: [PATCH 12/12] chore: trigger CI run --- scripts/ci/step_timer_start.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/step_timer_start.sh b/scripts/ci/step_timer_start.sh index 7d4e7c1b0..503143a6d 100755 --- a/scripts/ci/step_timer_start.sh +++ b/scripts/ci/step_timer_start.sh @@ -2,3 +2,4 @@ # CI 公共步骤:Job 开始计时 echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV echo "Job started at $(date)" +# trigger CI run for PR validation \ No newline at end of file -- 2.54.0