feat(ci): Validate阶段增加migration编号连续性检查 #652
Executable → Regular
+1
-1
@@ -197,8 +197,8 @@ export interface GenerationStatusResponse {
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
clips: ClipStatusItem[]
|
||||
error_message?: string
|
||||
clips: ClipStatusItem[]
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
Regular → Executable
+6
-5
@@ -1052,12 +1052,13 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerating(false)
|
||||
// 提取后端返回的错误详情,便于排查
|
||||
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const dataAny = data as Record<string, any>
|
||||
const rawMsg =
|
||||
data.error_message ||
|
||||
data.error ||
|
||||
data.message ||
|
||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
||||
?.error_message ||
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(data.clips || []).find((c) => c.status === "failed")?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
|
||||
const safeExtract = (val: unknown): string => {
|
||||
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
检查 Alembic migration 编号连续性。
|
||||
|
||||
扫描 alembic/versions/ 下所有 migration 文件,提取 revision 和 down_revision,
|
||||
验证整条链是否完整——每个 down_revision(除了 baseline 的 None)都必须对应一个存在的 revision。
|
||||
|
||||
支持两种格式:
|
||||
revision: str = "001" # 旧格式(带类型注解)
|
||||
revision = "038_error_retry" # 新格式(带描述后缀)
|
||||
|
||||
匹配策略:提取 revision 名称的数字前缀(如 "001"、"038")作为唯一标识进行匹配,
|
||||
兼容纯数字编号和"数字_描述"两种命名风格。
|
||||
|
||||
用法:
|
||||
python3 scripts/ci/check_migration_chain.py [alembic_versions_dir]
|
||||
|
||||
默认目录: alembic/versions/
|
||||
|
||||
退出码:
|
||||
0 - 链完整
|
||||
1 - 有断链或其他错误
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 匹配 revision / down_revision,支持带类型注解和不带类型注解两种格式
|
||||
# revision: str = "xxx" 或 revision = "xxx"
|
||||
REV_PATTERN = re.compile(
|
||||
r'^\s*revision\s*(?::\s*str\s*)?=\s*["\']([^"\']+)["\']',
|
||||
re.MULTILINE,
|
||||
)
|
||||
DOWN_PATTERN = re.compile(
|
||||
r'^\s*down_revision\s*(?::\s*(?:Union\[str,\s*None\]|str\s*\|\s*None|None|str)\s*)?=\s*(["\']([^"\']+)["\']|None)',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# 提取 revision 名称的数字前缀,如 "001" 或 "038_error_retry" → "038"
|
||||
NUM_PREFIX_PATTERN = re.compile(r"^(\d+)")
|
||||
|
||||
|
||||
def num_prefix(name: str) -> str:
|
||||
"""提取 revision 名称的数字前缀。"""
|
||||
m = NUM_PREFIX_PATTERN.match(name)
|
||||
return m.group(1) if m else name
|
||||
|
||||
|
||||
def extract_migration_info(filepath: Path) -> tuple[str, str | None]:
|
||||
"""从 migration 文件中提取 revision 和 down_revision(返回完整名称)。"""
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
|
||||
rev_match = REV_PATTERN.search(content)
|
||||
down_match = DOWN_PATTERN.search(content)
|
||||
|
||||
if not rev_match:
|
||||
raise ValueError(f"{filepath.name}: 未找到 revision 定义")
|
||||
|
||||
revision = rev_match.group(1)
|
||||
|
||||
if not down_match:
|
||||
raise ValueError(f"{filepath.name}: 未找到 down_revision 定义")
|
||||
|
||||
# down_match group(2) 是引号内的值,如果是 None 则 group(2) 为 None
|
||||
down_revision = down_match.group(2)
|
||||
|
||||
return revision, down_revision
|
||||
|
||||
|
||||
def check_chain(versions_dir: Path) -> list[str]:
|
||||
"""检查 migration 链是否完整,返回错误列表。"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not versions_dir.is_dir():
|
||||
return [f"目录不存在: {versions_dir}"]
|
||||
|
||||
py_files = sorted(versions_dir.glob("*.py"))
|
||||
if not py_files:
|
||||
return [f"目录下没有 migration 文件: {versions_dir}"]
|
||||
|
||||
# 收集所有 revision(用数字前缀做唯一标识)
|
||||
revisions_by_num: dict[str, str] = {} # 数字前缀 -> 完整 revision 名
|
||||
revision_files: dict[str, str] = {} # 数字前缀 -> 文件名
|
||||
down_revisions: list[tuple[str, str | None]] = [] # (文件名, down_revision 数字前缀或None)
|
||||
|
||||
for f in py_files:
|
||||
if f.name.startswith("__"):
|
||||
continue
|
||||
try:
|
||||
rev, down = extract_migration_info(f)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
continue
|
||||
|
||||
rev_num = num_prefix(rev)
|
||||
|
||||
if rev_num in revisions_by_num:
|
||||
errors.append(
|
||||
f"编号重复: 编号 {rev_num} 同时出现在 "
|
||||
f"{f.name} (revision={rev}) 和 {revision_files[rev_num]} (revision={revisions_by_num[rev_num]})"
|
||||
)
|
||||
else:
|
||||
revisions_by_num[rev_num] = rev
|
||||
revision_files[rev_num] = f.name
|
||||
|
||||
down_num = num_prefix(down) if down else None
|
||||
down_revisions.append((f.name, down_num))
|
||||
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
# 检查每个 down_revision 是否存在
|
||||
baselines = 0
|
||||
for filename, down_num in down_revisions:
|
||||
if down_num is None:
|
||||
baselines += 1
|
||||
continue
|
||||
|
||||
if down_num not in revisions_by_num:
|
||||
errors.append(
|
||||
f"断链: {filename} 的 down_revision 指向编号 '{down_num}',但没有任何 migration 的 revision 是这个编号"
|
||||
)
|
||||
|
||||
if baselines == 0:
|
||||
errors.append("没有找到 baseline migration(down_revision = None 的文件)")
|
||||
elif baselines > 1:
|
||||
errors.append(f"发现 {baselines} 个 baseline migration,通常只能有 1 个")
|
||||
|
||||
# 额外检查:数字编号是否连续(只对能提取出数字的)
|
||||
if revisions_by_num and not errors:
|
||||
nums = sorted(int(n) for n in revisions_by_num if n.isdigit())
|
||||
if nums:
|
||||
expected = list(range(nums[0], nums[-1] + 1))
|
||||
missing = [n for n in expected if n not in nums]
|
||||
if missing:
|
||||
missing_str = ", ".join(f"{n:03d}" for n in missing)
|
||||
errors.append(f"编号不连续: 缺少编号 {missing_str}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1:
|
||||
versions_dir = Path(sys.argv[1])
|
||||
else:
|
||||
versions_dir = Path("alembic/versions")
|
||||
|
||||
print(f"检查 migration 编号连续性: {versions_dir}")
|
||||
print()
|
||||
|
||||
errors = check_chain(versions_dir)
|
||||
|
||||
py_files = [f for f in versions_dir.glob("*.py") if not f.name.startswith("__")]
|
||||
|
||||
if errors:
|
||||
print(f"❌ Migration 链有问题(共 {len(py_files)} 个文件,{len(errors)} 个错误):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
print()
|
||||
print("请修复后再提交。常见原因:")
|
||||
print(" 1. 新 migration 的 down_revision 编号写错了")
|
||||
print(" 2. 多个 PR 同时加 migration,编号冲突")
|
||||
print(" 3. 合并代码时漏了某个 migration 文件")
|
||||
return 1
|
||||
|
||||
print(f"✅ Migration 链完整,共 {len(py_files)} 个版本")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user