Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c8b5f1a2 | |||
| d83d0a5d69 | |||
| cdb68636bf | |||
| ac1ae892ac | |||
| aeef4011de | |||
| 5abc6552f4 | |||
| e1db127123 | |||
| 6b1b2e710e | |||
| 143d11fe07 | |||
| 68d15d8731 | |||
| ac2662b693 | |||
| f89ce9f3e2 | |||
| 4c662cca32 | |||
| 28e2bba5c3 | |||
| c19e0d9387 | |||
| d9000fee54 | |||
| ffc9706a85 | |||
| bfce6f48ce |
@@ -4,17 +4,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- feature/**
|
||||
- feat/**
|
||||
- bugfix/**
|
||||
- fix/**
|
||||
- hotfix/**
|
||||
- release/**
|
||||
- refactor/**
|
||||
- perf/**
|
||||
- docs/**
|
||||
- chore/**
|
||||
- ci/**
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
@@ -35,7 +24,7 @@ concurrency:
|
||||
jobs:
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: host
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
strategy:
|
||||
@@ -157,8 +146,11 @@ jobs:
|
||||
'
|
||||
deploy-staging:
|
||||
name: Deploy Staging (Watchtower auto-deploy)
|
||||
runs-on: saas
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: deploy-staging-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
needs:
|
||||
- build-staging
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
@@ -308,7 +300,7 @@ jobs:
|
||||
'
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
runs-on: saas
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 15
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
@@ -388,7 +380,7 @@ jobs:
|
||||
'
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
@@ -468,7 +460,7 @@ jobs:
|
||||
'
|
||||
build-production:
|
||||
name: Build Production ${{ matrix.service_display }} Image
|
||||
runs-on: host
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -592,8 +584,11 @@ jobs:
|
||||
'
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: saas
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: deploy-production-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs:
|
||||
- build-production
|
||||
@@ -735,7 +730,7 @@ jobs:
|
||||
'
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: saas
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
+51
-18
@@ -4,17 +4,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- feature/**
|
||||
- feat/**
|
||||
- bugfix/**
|
||||
- fix/**
|
||||
- hotfix/**
|
||||
- release/**
|
||||
- refactor/**
|
||||
- perf/**
|
||||
- docs/**
|
||||
- chore/**
|
||||
- ci/**
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
@@ -30,12 +19,48 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
else
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "🔧 包含后端/公共变更,运行完整CI"
|
||||
fi
|
||||
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: host
|
||||
runs-on: ci-l1
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
@@ -77,6 +102,10 @@ jobs:
|
||||
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
|
||||
# Force source install of black/isort to ensure consistent formatting
|
||||
# across compiled/source installations on different machines
|
||||
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
|
||||
|
||||
python3 -m black --version
|
||||
|
||||
python3 -m isort --version-number
|
||||
@@ -190,8 +219,10 @@ jobs:
|
||||
|
||||
'
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Unit Tests
|
||||
runs-on: host
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
USE_IN_MEMORY_DB: 'true'
|
||||
@@ -262,10 +293,12 @@ jobs:
|
||||
'
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: host
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 30
|
||||
if: always()
|
||||
needs: validate
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -379,7 +412,7 @@ jobs:
|
||||
'
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: host
|
||||
runs-on: ci-l1
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: AI Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
# 同一个 PR 只跑一个 review,新的取消旧的
|
||||
concurrency:
|
||||
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ubuntu-latest
|
||||
# 跳过草稿 PR
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install requests
|
||||
|
||||
- name: Run AI Code Review
|
||||
env:
|
||||
# Gitea 配置(自动从运行环境获取)
|
||||
GITEA_API_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
||||
# 可选参数
|
||||
MAX_DIFF_CHARS: "30000"
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Code Review Script
|
||||
- 从 Gitea 获取 PR diff
|
||||
- 调用 LLM 进行代码审查
|
||||
- 将审查结果写回 PR 评论
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
# ============== 日志配置 ==============
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("ci_code_review")
|
||||
|
||||
|
||||
# ============== 常量配置 ==============
|
||||
# diff 最大字符数(超过则截断)
|
||||
MAX_DIFF_CHARS = int(os.getenv("MAX_DIFF_CHARS", "30000"))
|
||||
# LLM 调用超时时间(秒)
|
||||
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120"))
|
||||
# Gitea API 超时时间(秒)
|
||||
GITEA_TIMEOUT = int(os.getenv("GITEA_TIMEOUT", "30"))
|
||||
# 最大重试次数
|
||||
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2"))
|
||||
# LLM 提供商: openai (OpenAI兼容) / coze (扣子原生Bot API)
|
||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "coze").lower()
|
||||
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
def truncate_diff(diff_text: str, max_chars: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
截断过大的 diff 内容,避免超出 LLM 上下文限制。
|
||||
优先保留文件头和前面的变更,末尾加提示。
|
||||
"""
|
||||
if len(diff_text) <= max_chars:
|
||||
return diff_text, False
|
||||
|
||||
# 找到一个合适的截断位置(尽量在文件边界)
|
||||
truncated = diff_text[:max_chars]
|
||||
# 尝试在最后一个 "diff --git" 处截断,避免截断到一半
|
||||
last_file_boundary = truncated.rfind("\ndiff --git ")
|
||||
if last_file_boundary > max_chars // 2:
|
||||
truncated = truncated[:last_file_boundary]
|
||||
|
||||
truncated += (
|
||||
f"\n\n... [DIFF TRUNCATED] 原始 diff 共 {len(diff_text)} 字符,"
|
||||
f"已截断至 {len(truncated)} 字符,仅审查前半部分。\n"
|
||||
)
|
||||
return truncated, True
|
||||
|
||||
|
||||
def get_env_or_fail(name: str) -> str:
|
||||
"""从环境变量获取值,不存在则报错退出。"""
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
logger.error(f"环境变量 {name} 未设置")
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
|
||||
# ============== Gitea API 相关 ==============
|
||||
class GiteaClient:
|
||||
"""Gitea API 客户端"""
|
||||
|
||||
def __init__(self, base_url: str, token: str, repo: str):
|
||||
# 确保 base_url 以 / 结尾
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.token = token
|
||||
self.repo = repo # 格式: owner/repo
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
"""拼接 API 路径"""
|
||||
return f"{self.base_url}api/v1/repos/{self.repo}/{path.lstrip('/')}"
|
||||
|
||||
def get_pr_diff(self, pr_number: int) -> str:
|
||||
"""
|
||||
获取 PR 的 diff 内容。
|
||||
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}.diff
|
||||
"""
|
||||
url = self._api_url(f"pulls/{pr_number}.diff")
|
||||
logger.info(f"获取 PR #{pr_number} diff: {url}")
|
||||
|
||||
resp = self.session.get(
|
||||
url,
|
||||
timeout=GITEA_TIMEOUT,
|
||||
headers={
|
||||
"Accept": "text/plain",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"获取 diff 失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
raise RuntimeError(f"Failed to get PR diff: HTTP {resp.status_code}")
|
||||
|
||||
diff_text = resp.text
|
||||
logger.info(f"获取到 diff,共 {len(diff_text)} 字符")
|
||||
return diff_text
|
||||
|
||||
def get_pr_files(self, pr_number: int) -> list:
|
||||
"""
|
||||
获取 PR 修改的文件列表。
|
||||
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}/files
|
||||
"""
|
||||
url = self._api_url(f"pulls/{pr_number}/files")
|
||||
logger.info(f"获取 PR #{pr_number} 文件列表")
|
||||
|
||||
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"获取文件列表失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
files = resp.json()
|
||||
logger.info(f"PR 修改了 {len(files)} 个文件")
|
||||
return files
|
||||
|
||||
def post_pr_comment(self, pr_number: int, body: str) -> bool:
|
||||
"""
|
||||
在 PR 上发布评论。
|
||||
Gitea API: POST /repos/{owner}/{repo}/issues/{index}/comments
|
||||
(Gitea 中 PR 评论走 issues 接口)
|
||||
"""
|
||||
url = self._api_url(f"issues/{pr_number}/comments")
|
||||
logger.info(f"发布 PR 评论: {url}")
|
||||
|
||||
payload = {"body": body}
|
||||
resp = self.session.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error(f"发布评论失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
logger.info(f"评论发布成功,评论 ID: {resp.json().get('id', 'unknown')}")
|
||||
return True
|
||||
|
||||
def get_existing_review_comments(self, pr_number: int, marker: str) -> list:
|
||||
"""
|
||||
获取 PR 上已有的 AI 审查评论 ID 列表(带标识 marker)。
|
||||
"""
|
||||
url = self._api_url(f"issues/{pr_number}/comments")
|
||||
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"获取评论列表失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
comments = resp.json()
|
||||
review_comment_ids = []
|
||||
for c in comments:
|
||||
body = c.get("body", "")
|
||||
if marker in body:
|
||||
review_comment_ids.append(c.get("id"))
|
||||
logger.info(f"找到 {len(review_comment_ids)} 条旧的 AI 审查评论")
|
||||
return review_comment_ids
|
||||
|
||||
def delete_pr_comment(self, pr_number: int, comment_id: int) -> bool:
|
||||
"""
|
||||
删除 PR 上的指定评论。
|
||||
"""
|
||||
url = self._api_url(f"issues/comments/{comment_id}")
|
||||
resp = self.session.delete(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code not in (200, 204):
|
||||
logger.warning(f"删除评论 {comment_id} 失败: HTTP {resp.status_code}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def call_llm_openai(
|
||||
prompt: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
) -> Optional[str]:
|
||||
"""OpenAI 兼容模式调用"""
|
||||
base_url = llm_base_url.rstrip("/") + "/"
|
||||
api_url = f"{base_url}chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一位严谨的资深代码审查专家,擅长发现代码中的逻辑错误、安全隐患和性能问题。",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
|
||||
logger.info(f"调用 LLM (OpenAI兼容): {api_url}, model={llm_model}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"LLM 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning(f"LLM 返回空结果 (第 {attempt + 1} 次)")
|
||||
last_error = "empty choices"
|
||||
continue
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
if not content.strip():
|
||||
logger.warning(f"LLM 返回空内容 (第 {attempt + 1} 次)")
|
||||
last_error = "empty content"
|
||||
continue
|
||||
|
||||
logger.info(f"LLM 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"LLM 调用超时 (第 {attempt + 1} 次)")
|
||||
last_error = "timeout"
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"LLM 调用异常 (第 {attempt + 1} 次): {e}")
|
||||
last_error = str(e)
|
||||
|
||||
logger.error(f"LLM 调用最终失败: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def call_llm_coze(
|
||||
prompt: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
coze_bot_id: str,
|
||||
) -> Optional[str]:
|
||||
"""扣子(Coze)原生 Bot API 调用(支持异步轮询)"""
|
||||
import time
|
||||
|
||||
base_url = llm_base_url.rstrip("/") + "/"
|
||||
api_url = f"{base_url}v3/chat"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"bot_id": coze_bot_id,
|
||||
"user_id": "ci-code-review-bot",
|
||||
"stream": False,
|
||||
"additional_messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"content_type": "text",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
logger.info(f"调用 LLM (Coze): {api_url}, bot_id={coze_bot_id}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Coze 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:300]}")
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
chat_data = data.get("data", {})
|
||||
chat_id = chat_data.get("id", "")
|
||||
conversation_id = chat_data.get("conversation_id", "")
|
||||
status = chat_data.get("status", "")
|
||||
|
||||
# Coze v3 API 异步:先返回 in_progress,需要轮询
|
||||
if status == "in_progress" and conversation_id and chat_id:
|
||||
logger.info(f"Coze 异步处理中,开始轮询... (chat_id={chat_id[:12]}...)")
|
||||
# 轮询 message 列表接口(GET + query参数),最多等 LLM_TIMEOUT 秒
|
||||
poll_url = f"{base_url}v3/chat/message/list"
|
||||
poll_start = time.time()
|
||||
poll_interval = 3 # 每3秒轮询一次
|
||||
|
||||
while time.time() - poll_start < LLM_TIMEOUT:
|
||||
time.sleep(poll_interval)
|
||||
poll_params = {
|
||||
"chat_id": chat_id,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
poll_resp = requests.get(
|
||||
poll_url,
|
||||
headers=headers,
|
||||
params=poll_params,
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if poll_resp.status_code != 200:
|
||||
logger.debug(f"轮询返回 HTTP {poll_resp.status_code}: {poll_resp.text[:100]}")
|
||||
continue
|
||||
|
||||
poll_data = poll_resp.json()
|
||||
if poll_data.get("code", 0) != 0:
|
||||
logger.debug(f"轮询返回错误: {poll_data.get('msg', '')}")
|
||||
continue
|
||||
|
||||
messages = poll_data.get("data", []) or []
|
||||
|
||||
# 找assistant的answer消息
|
||||
content = None
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||
content = msg.get("content", "")
|
||||
break
|
||||
|
||||
if content and content.strip():
|
||||
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
logger.warning(f"Coze 轮询超时 ({LLM_TIMEOUT}s),未拿到结果")
|
||||
last_error = "poll timeout"
|
||||
continue
|
||||
|
||||
# 同步返回的情况(兼容)
|
||||
content = None
|
||||
messages = chat_data.get("messages", []) or data.get("messages", [])
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||
content = msg.get("content", "")
|
||||
break
|
||||
|
||||
if not content:
|
||||
content = chat_data.get("content") or data.get("content")
|
||||
|
||||
if not content:
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
|
||||
if not content or not content.strip():
|
||||
logger.warning(f"Coze 返回空内容 (第 {attempt + 1} 次): {str(data)[:200]}")
|
||||
last_error = "empty content"
|
||||
continue
|
||||
|
||||
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Coze 调用超时 (第 {attempt + 1} 次)")
|
||||
last_error = "timeout"
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Coze 调用异常 (第 {attempt + 1} 次): {e}")
|
||||
last_error = str(e)
|
||||
|
||||
logger.error(f"Coze 调用最终失败: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def call_llm_for_review(
|
||||
diff_text: str,
|
||||
pr_number: int,
|
||||
file_list: list,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
coze_bot_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
调用 LLM 进行代码审查,返回审查结果文本。
|
||||
失败时返回 None。
|
||||
根据 LLM_PROVIDER 环境变量选择调用方式。
|
||||
"""
|
||||
prompt = build_review_prompt(diff_text, pr_number, file_list)
|
||||
logger.info(f"Prompt 长度: {len(prompt)} 字符")
|
||||
|
||||
provider = LLM_PROVIDER
|
||||
|
||||
if provider == "coze":
|
||||
return call_llm_coze(prompt, llm_base_url, llm_api_key, llm_model, coze_bot_id)
|
||||
else:
|
||||
# 默认 OpenAI 兼容
|
||||
return call_llm_openai(prompt, llm_base_url, llm_api_key, llm_model)
|
||||
|
||||
|
||||
# ============== 主流程 ==============
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI AI 代码审查脚本")
|
||||
parser.add_argument("--pr", type=int, help="PR 编号(也可通过 PR_NUMBER 环境变量)")
|
||||
parser.add_argument("--repo", type=str, help="仓库名 owner/repo(也可通过 REPO_NAME 环境变量)")
|
||||
parser.add_argument("--gitea-url", type=str, help="Gitea 地址(也可通过 GITEA_API_URL 环境变量)")
|
||||
parser.add_argument("--gitea-token", type=str, help="Gitea Token(也可通过 GITEA_TOKEN 环境变量)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只输出审查结果,不发表评论")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 读取配置
|
||||
gitea_url = args.gitea_url or os.getenv("GITEA_API_URL") or os.getenv("GITEA_SERVER_URL")
|
||||
gitea_token = args.gitea_token or os.getenv("GITEA_TOKEN")
|
||||
repo_name = args.repo or os.getenv("REPO_NAME") or os.getenv("GITEA_REPO")
|
||||
pr_number = args.pr or int(os.getenv("PR_NUMBER") or os.getenv("GITEA_PR_NUMBER") or 0)
|
||||
|
||||
llm_base_url = os.getenv("LLM_BASE_URL")
|
||||
llm_api_key = os.getenv("LLM_API_KEY")
|
||||
llm_model = os.getenv("LLM_MODEL", "")
|
||||
coze_bot_id = os.getenv("COZE_BOT_ID", os.getenv("COZE_BOTID", ""))
|
||||
|
||||
# 根据 provider 设置默认值
|
||||
provider = LLM_PROVIDER
|
||||
if provider == "coze":
|
||||
# 扣子模式:默认国内站,key 兼容多种环境变量名
|
||||
if not llm_base_url:
|
||||
llm_base_url = "https://api.coze.cn"
|
||||
if not llm_api_key:
|
||||
llm_api_key = os.getenv("COZE_API_KEY", "") or os.getenv("COZE_PAT", "")
|
||||
else:
|
||||
# OpenAI兼容模式:默认模型
|
||||
if not llm_model:
|
||||
llm_model = "gpt-4o-mini"
|
||||
|
||||
# 必要参数校验
|
||||
missing = []
|
||||
if not gitea_url:
|
||||
missing.append("GITEA_API_URL")
|
||||
if not gitea_token:
|
||||
missing.append("GITEA_TOKEN")
|
||||
if not repo_name:
|
||||
missing.append("REPO_NAME")
|
||||
if not pr_number:
|
||||
missing.append("PR_NUMBER")
|
||||
if not llm_base_url:
|
||||
missing.append("LLM_BASE_URL")
|
||||
if not llm_api_key:
|
||||
missing.append("LLM_API_KEY")
|
||||
if provider == "coze" and not coze_bot_id:
|
||||
missing.append("COZE_BOT_ID (扣子模式需要)")
|
||||
|
||||
if missing:
|
||||
logger.error(f"缺少必要配置: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"开始审查 PR #{pr_number},仓库: {repo_name}")
|
||||
logger.info(f"Gitea: {gitea_url}")
|
||||
logger.info(f"LLM: {llm_base_url} (model={llm_model})")
|
||||
|
||||
try:
|
||||
# 1. 初始化 Gitea 客户端
|
||||
gitea = GiteaClient(gitea_url, gitea_token, repo_name)
|
||||
|
||||
# 2. 获取 PR diff 和文件列表
|
||||
try:
|
||||
diff_text = gitea.get_pr_diff(pr_number)
|
||||
file_list = gitea.get_pr_files(pr_number)
|
||||
except Exception as e:
|
||||
logger.error(f"获取 PR 信息失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件、二进制文件等)
|
||||
skip_extensions = (
|
||||
".lock",
|
||||
".sum",
|
||||
".min.js",
|
||||
".min.css",
|
||||
".map",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".ico",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot",
|
||||
)
|
||||
skipped_files = []
|
||||
if file_list:
|
||||
skipped_files = [
|
||||
f.get("filename")
|
||||
for f in file_list
|
||||
if f.get("filename", "").endswith(skip_extensions) or f.get("status") == "removed"
|
||||
]
|
||||
if skipped_files:
|
||||
logger.info(f"跳过 {len(skipped_files)} 个非文本/已删除文件: {', '.join(skipped_files[:5])}...")
|
||||
|
||||
# 实际从 diff 中移除跳过的文件(按文件边界切割)
|
||||
if skipped_files:
|
||||
diff_lines = diff_text.split("\n")
|
||||
filtered_lines = []
|
||||
current_file = None
|
||||
skip_current = False
|
||||
i = 0
|
||||
while i < len(diff_lines):
|
||||
line = diff_lines[i]
|
||||
# 检测新文件开始: diff --git a/xxx b/xxx
|
||||
if line.startswith("diff --git "):
|
||||
# 提取文件名
|
||||
parts = line.split(" ")
|
||||
if len(parts) >= 4:
|
||||
# b/ 后面的是目标文件名
|
||||
current_file = parts[3][2:] if parts[3].startswith("b/") else parts[3]
|
||||
skip_current = any(current_file == sf for sf in skipped_files) or any(
|
||||
current_file.endswith(ext) for ext in skip_extensions
|
||||
)
|
||||
else:
|
||||
skip_current = False
|
||||
if not skip_current:
|
||||
filtered_lines.append(line)
|
||||
i += 1
|
||||
original_len = len(diff_text)
|
||||
diff_text = "\n".join(filtered_lines)
|
||||
logger.info(f"Diff 过滤后: {original_len} -> {len(diff_text)} 字符 (减少 {original_len - len(diff_text)})")
|
||||
|
||||
# 4. 截断过大的 diff
|
||||
diff_text, was_truncated = truncate_diff(diff_text, MAX_DIFF_CHARS)
|
||||
if was_truncated:
|
||||
logger.warning(f"Diff 过大,已截断至 {len(diff_text)} 字符")
|
||||
|
||||
# 5. 如果 diff 为空,直接跳过
|
||||
if not diff_text.strip():
|
||||
logger.info("Diff 为空,无需审查")
|
||||
sys.exit(0)
|
||||
|
||||
# 6. 调用 LLM 审查
|
||||
review_result = call_llm_for_review(
|
||||
diff_text=diff_text,
|
||||
pr_number=pr_number,
|
||||
file_list=file_list,
|
||||
llm_base_url=llm_base_url,
|
||||
llm_api_key=llm_api_key,
|
||||
llm_model=llm_model,
|
||||
coze_bot_id=coze_bot_id,
|
||||
)
|
||||
|
||||
if not review_result:
|
||||
logger.error("LLM 审查失败")
|
||||
sys.exit(1)
|
||||
|
||||
# 7. 加上审查时间和标识(便于识别是自动审查)
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
marker = "<!-- AI_CODE_REVIEW_AUTO_COMMENT -->"
|
||||
full_comment = f"""{review_result}
|
||||
|
||||
---
|
||||
<sub>🤖 由 AI 代码审查机器人自动生成 | {timestamp} | 模型: {llm_model}</sub>
|
||||
|
||||
{marker}
|
||||
"""
|
||||
|
||||
# 8. 输出审查结果到日志
|
||||
logger.info("=" * 60)
|
||||
logger.info("审查结果:")
|
||||
for line in review_result.split("\n")[:30]:
|
||||
logger.info(line)
|
||||
if len(review_result.split("\n")) > 30:
|
||||
logger.info(f"... 共 {len(review_result.split(chr(10)))} 行")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 9. 发布评论(先删除旧的审查评论,避免刷屏)
|
||||
if args.dry_run:
|
||||
logger.info("--dry-run 模式,跳过发布评论")
|
||||
print(full_comment)
|
||||
else:
|
||||
# 去重:删除之前的 AI 审查评论
|
||||
old_comments = gitea.get_existing_review_comments(pr_number, marker)
|
||||
if old_comments:
|
||||
logger.info(f"找到 {len(old_comments)} 条旧的 AI 审查评论,先删除")
|
||||
for cid in old_comments:
|
||||
gitea.delete_pr_comment(pr_number, cid)
|
||||
# 发布新评论
|
||||
success = gitea.post_pr_comment(pr_number, full_comment)
|
||||
if not success:
|
||||
logger.error("评论发布失败")
|
||||
sys.exit(1)
|
||||
|
||||
# 10. 判断是否有严重问题(可选阻断)
|
||||
# 目前只做建议,不阻断合并,始终返回 0
|
||||
has_critical = "问题" in review_result and ("❌" in review_result or "需修改" in review_result)
|
||||
if has_critical:
|
||||
logger.warning("检测到需修改的问题,但当前配置为仅建议,不阻断合并")
|
||||
|
||||
logger.info("代码审查完成")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+208
-19
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式)
|
||||
# Staging 部署脚本(SSH 模式,支持自动回滚)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 staging 服务器上执行
|
||||
#
|
||||
@@ -12,9 +12,44 @@
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
# ---- 重试工具函数 ----
|
||||
retry_cmd() {
|
||||
local max_attempts=$1
|
||||
local backoff=$2
|
||||
shift 2
|
||||
local attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
echo " attempt $attempt/$max_attempts failed, retrying in ${backoff}s..."
|
||||
sleep $backoff
|
||||
backoff=$((backoff * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo " ERROR: failed after $max_attempts retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_docker_login() {
|
||||
echo "Logging in to registry (up to 3 retries)"
|
||||
if retry_cmd 3 5 sh -c "printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin"; then
|
||||
return 0
|
||||
fi
|
||||
echo "WARN: docker login failed after retries, will try pull anyway"
|
||||
return 0
|
||||
}
|
||||
|
||||
retry_docker_pull() {
|
||||
local image=$1
|
||||
echo "Pulling $image (up to 3 retries)"
|
||||
retry_cmd 3 10 docker pull "$image"
|
||||
}
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
|
||||
@@ -25,6 +60,7 @@ GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
@@ -35,17 +71,165 @@ test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo "==========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG"
|
||||
echo "=========================================="
|
||||
echo "==========================================="
|
||||
|
||||
# ---- 记录当前运行的镜像版本(用于回滚) ----
|
||||
echo "Recording current image versions for rollback..."
|
||||
PREV_API_IMAGE=""
|
||||
PREV_WORKER_IMAGE=""
|
||||
PREV_WEB_IMAGE=""
|
||||
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
if docker inspect "$c" >/dev/null 2>&1; then
|
||||
img=$(docker inspect -f '{{.Config.Image}}' "$c")
|
||||
case "$c" in
|
||||
xiaoxia-api-staging) PREV_API_IMAGE="$img" ;;
|
||||
xiaoxia-worker-staging) PREV_WORKER_IMAGE="$img" ;;
|
||||
xiaoxia-web-staging) PREV_WEB_IMAGE="$img" ;;
|
||||
esac
|
||||
echo " $c -> $img"
|
||||
else
|
||||
echo " $c -> (not running)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 回滚函数 ----
|
||||
rollback() {
|
||||
echo ""
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo " 部署失败,正在自动回滚到上一版本..."
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo ""
|
||||
|
||||
if [ "$SKIP_ROLLBACK" = "true" ]; then
|
||||
echo "SKIP_ROLLBACK=true,跳过自动回滚"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止当前(失败的)新容器
|
||||
echo "Stopping new containers..."
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 恢复 API
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Rolling back API to: $PREV_API_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_API_IMAGE"
|
||||
else
|
||||
echo "No previous API image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Worker
|
||||
if [ -n "$PREV_WORKER_IMAGE" ]; then
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WORKER_IMAGE"
|
||||
else
|
||||
echo "No previous Worker image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Web
|
||||
if [ -n "$PREV_WEB_IMAGE" ]; then
|
||||
echo "Rolling back Web to: $PREV_WEB_IMAGE"
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
fi
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WEB_IMAGE"
|
||||
else
|
||||
echo "No previous Web image to roll back to"
|
||||
fi
|
||||
|
||||
# 等待 API 回滚后恢复健康
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Waiting for rolled-back API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "Rolled-back API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "WARN: Rolled-back API did not become healthy within 120s"
|
||||
docker logs --tail 30 xiaoxia-api-staging
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " 回滚完成"
|
||||
echo "==========================================="
|
||||
echo "Previous API: ${PREV_API_IMAGE:-none}"
|
||||
echo "Previous Worker: ${PREV_WORKER_IMAGE:-none}"
|
||||
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
|
||||
echo ""
|
||||
echo "部署失败,已自动回滚到上一版本"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
echo "=========================================="
|
||||
echo " Login to Registry (with retries)"
|
||||
echo "=========================================="
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
retry_docker_login
|
||||
fi
|
||||
|
||||
# ---- Pull 新版本镜像 ----
|
||||
@@ -57,12 +241,12 @@ LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
echo "=========================================="
|
||||
echo " Pull images (with retries)"
|
||||
echo "=========================================="
|
||||
retry_docker_pull "$REGISTRY_API"
|
||||
retry_docker_pull "$REGISTRY_WORKER"
|
||||
retry_docker_pull "$REGISTRY_WEB"
|
||||
|
||||
# Re-tag 成本地名
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
@@ -117,7 +301,12 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head" || {
|
||||
echo "ERROR: Database migration failed"
|
||||
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
|
||||
echo "Please manually check and fix the migration, then redeploy"
|
||||
exit 1
|
||||
}
|
||||
echo "Migrations completed."
|
||||
else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
@@ -151,7 +340,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
"$LOCAL_API" || rollback
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
@@ -174,7 +363,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
"$LOCAL_WORKER" || rollback
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
@@ -197,7 +386,7 @@ docker run -d \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
"$LOCAL_WEB" || rollback
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
@@ -215,7 +404,7 @@ done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
exit 1
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
@@ -234,7 +423,7 @@ done
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
exit 1
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
|
||||
Reference in New Issue
Block a user