Merge branch 'develop' of https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas into fix/ci-required-checks-coverage-916
AI Code Review / AI Code Review (pull_request) Failing after 0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m21s
AI Code Review / AI Code Review (pull_request) Failing after 0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m21s
This commit is contained in:
@@ -1620,7 +1620,6 @@ jobs:
|
||||
set -eu
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--keep 20 \
|
||||
--pr-days 7 \
|
||||
--execute
|
||||
|
||||
- name: Job duration summary
|
||||
|
||||
@@ -93,44 +93,31 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
|
||||
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
|
||||
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
|
||||
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
|
||||
fi
|
||||
cd apps/web
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
|
||||
-w /workspace/apps/web \
|
||||
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc '
|
||||
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
|
||||
CACHE_HASH_FILE="node_modules/.package-lock-hash"
|
||||
CACHE_VALID=false
|
||||
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
|
||||
CACHE_VALID=true
|
||||
echo "Cache hit: dependencies valid, skipping npm ci"
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
fi
|
||||
echo "Running TypeScript check..."
|
||||
npx --no-install tsc
|
||||
echo "Running Vite build..."
|
||||
npx --no-install vite build
|
||||
echo "Build completed successfully"
|
||||
ls -la dist/
|
||||
'
|
||||
# Config npm mirror for speed
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm ci failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# TypeScript check
|
||||
echo "=== TypeScript check ==="
|
||||
npx --no-install tsc --noEmit
|
||||
|
||||
# Vite build
|
||||
echo "=== Vite build ==="
|
||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||||
npx --no-install vite build
|
||||
|
||||
echo "=== Build completed ==="
|
||||
ls -la dist/
|
||||
|
||||
- name: Install SSH client and rsync
|
||||
shell: sh
|
||||
|
||||
@@ -430,7 +430,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
|
||||
@@ -6,6 +6,7 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
@@ -87,7 +88,7 @@ def _fallback_recommend_clips(
|
||||
order += 1
|
||||
|
||||
# 生成推荐 config
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
|
||||
config["title"]["ai_auto"] = True
|
||||
|
||||
@@ -167,7 +168,7 @@ def _parse_recommend_response(
|
||||
for i, clip in enumerate(clips):
|
||||
clip["order"] = i
|
||||
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
title = data.get("title", "")
|
||||
if title:
|
||||
config["title"]["text"] = str(title)
|
||||
|
||||
@@ -45,6 +45,9 @@ for i in 1 2 3; do
|
||||
done
|
||||
pytest --version
|
||||
|
||||
# --- 安装 ffmpeg(视频处理相关测试依赖)---
|
||||
bash scripts/ci/step_install_ffmpeg.sh
|
||||
|
||||
# 双保险:确保numpy已安装
|
||||
python3 -m pip install -q numpy==1.26.4 || true
|
||||
|
||||
|
||||
@@ -56,95 +56,18 @@ for fpath, items in data.get('results', {}).items():
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks ==="
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
CHANGED_PY_FILES=""
|
||||
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
|
||||
|
||||
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('')
|
||||
")
|
||||
# 新增文件(added)强制全量检查,防止增量漏检
|
||||
ADDED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
added = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] == 'added']
|
||||
print(' '.join(added))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
MODIFIED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
modified = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] not in ('removed', 'added')]
|
||||
print(' '.join(modified))
|
||||
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 安全扫描(仅告警) ---
|
||||
|
||||
@@ -11,8 +11,7 @@ class TestMergeBgmConfigBothEmpty:
|
||||
def test_both_empty(self):
|
||||
result = merge_bgm_config({}, {})
|
||||
assert result == {}
|
||||
# 确保返回的是新字典,不是同一个引用
|
||||
assert result is not {}
|
||||
# 返回新字典(值已通过 == 验证,is not {} 无实际意义(每次{}每次新建对象)
|
||||
|
||||
def test_user_none_returns_template_copy(self):
|
||||
"""用户传 None 视为空配置,返回模板副本。"""
|
||||
|
||||
@@ -245,7 +245,7 @@ class TestClassificationJobState:
|
||||
assert job.confidence == 1.0
|
||||
|
||||
|
||||
class TestClassificationJobStatusMissing:
|
||||
class TestClassificationJobStatusMissingAliases:
|
||||
"""ClassificationJobStatus._missing_ 兼容行为测试"""
|
||||
|
||||
def test_done_maps_to_completed(self):
|
||||
|
||||
@@ -8,13 +8,9 @@ from unittest.mock import MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2)
|
||||
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
|
||||
# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入
|
||||
|
||||
|
||||
def _mock_module(**attrs):
|
||||
"""创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__"""
|
||||
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
|
||||
m = MagicMock()
|
||||
m.__spec__ = None
|
||||
for k, v in attrs.items():
|
||||
@@ -22,37 +18,60 @@ def _mock_module(**attrs):
|
||||
return m
|
||||
|
||||
|
||||
# cv2(视频处理依赖,纯算法测试不需要)
|
||||
# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
|
||||
# This pattern ensures:
|
||||
# 1. dedup is imported with mocks active (no db/celery/cv2 side effects)
|
||||
# 2. sys.modules is restored immediately so other test files are not polluted
|
||||
# 3. dedup objects are kept in module namespace for tests to use
|
||||
|
||||
_SAVED_MODULES_KEYS = set(sys.modules.keys())
|
||||
_SAVED_MODULES_VALUES = {
|
||||
k: sys.modules.get(k)
|
||||
for k in [
|
||||
"cv2",
|
||||
"celery",
|
||||
"sqlalchemy",
|
||||
"sqlalchemy.orm",
|
||||
"sqlalchemy.engine",
|
||||
"sqlalchemy.ext",
|
||||
"sqlalchemy.ext.declarative",
|
||||
"worker_app.db",
|
||||
"worker_app.celery_app",
|
||||
"worker_app.core.config",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository",
|
||||
"packages.shared.config",
|
||||
"packages.shared.storage",
|
||||
]
|
||||
}
|
||||
|
||||
# Set up mocks
|
||||
sys.modules["cv2"] = _mock_module()
|
||||
|
||||
# celery 及其子模块
|
||||
_mock_celery = MagicMock()
|
||||
_mock_celery.Task = MagicMock
|
||||
_mock_celery.Celery = MagicMock
|
||||
_mock_celery.__spec__ = None
|
||||
sys.modules["celery"] = _mock_celery
|
||||
|
||||
# sqlalchemy(dedup 导入了 Session 类型)
|
||||
_mock_sqla = MagicMock()
|
||||
_mock_sqla.__path__ = []
|
||||
_mock_sqla.__spec__ = None
|
||||
sys.modules["sqlalchemy"] = _mock_sqla
|
||||
|
||||
_mock_sqla_orm = MagicMock()
|
||||
_mock_sqla_orm.__path__ = []
|
||||
_mock_sqla_orm.__spec__ = None
|
||||
_mock_sqla_orm.Session = MagicMock
|
||||
sys.modules["sqlalchemy"] = _mock_sqla
|
||||
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
|
||||
sys.modules["sqlalchemy.engine"] = _mock_module()
|
||||
sys.modules["sqlalchemy.ext"] = _mock_module()
|
||||
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
|
||||
|
||||
# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包)
|
||||
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
|
||||
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
|
||||
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
|
||||
|
||||
# packages - 只 mock 真正触发副作用的模块,不 mock 整个父包
|
||||
# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
|
||||
Base=MagicMock(),
|
||||
build_engine=MagicMock(),
|
||||
@@ -64,12 +83,26 @@ sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _m
|
||||
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
|
||||
sys.modules["packages.shared.storage"] = _mock_module()
|
||||
|
||||
# Import dedup while mocks are active
|
||||
from video_processing.dedup import ( # noqa: E402
|
||||
VideoDeduplicator,
|
||||
VideoFingerprint,
|
||||
hamming_distance,
|
||||
)
|
||||
|
||||
# ── Restore sys.modules immediately after import ──
|
||||
# dedup is now cached in this module's namespace; other test files will get
|
||||
# their own fresh imports without our mock pollution
|
||||
for _key in list(sys.modules.keys()):
|
||||
if _key not in _SAVED_MODULES_KEYS:
|
||||
del sys.modules[_key]
|
||||
for _key, _value in _SAVED_MODULES_VALUES.items():
|
||||
if _value is not None:
|
||||
sys.modules[_key] = _value
|
||||
elif _key in sys.modules:
|
||||
del sys.modules[_key]
|
||||
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value
|
||||
|
||||
|
||||
class TestHammingDistance:
|
||||
"""hamming_distance 汉明距离计算测试."""
|
||||
|
||||
@@ -208,7 +208,7 @@ class TestGenerationTaskStateTransitions:
|
||||
|
||||
def test_transition_to_invalid_string_raises(self):
|
||||
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.transition_to("invalid_status")
|
||||
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ class TestGenerationTaskTransitions:
|
||||
|
||||
def test_invalid_status_string(self, new_task):
|
||||
"""测试无效状态字符串"""
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
new_task.transition_to("invalid_status")
|
||||
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ class TestTransitionTo:
|
||||
def test_invalid_string_raises(self) -> None:
|
||||
"""无效的状态字符串抛出 ValueError。"""
|
||||
task = _make_task()
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
task.transition_to("invalid_status")
|
||||
|
||||
def test_enum_status(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user