split: handler mixins for oauth/identity/project/automation/fullauto (94 methods) on integration base 02835f6

- CloudControlPlaneHandler mixes in OAuthMixin, IdentityMemoryMixin,
  ProjectMixin, AutomationMixin, FullAutoMixin; shared helpers move to
  cloud/handler_support.py with server.py re-exports (old import paths
  keep working, incl. _validate_env_branch for tests).
- Method bodies moved verbatim; routers, base plumbing, task/lifecycle,
  webhooks stay in server.py. No >100-line method body split.
- test_handler_split_conformance.py (6 tests) pins resolution, layer
  bans and method accounting (238 total, 144 + 94, no duplicates).
- test_p1_security_server.py: monkeypatch targets follow the defining
  module (handler_project MAX_PROJECTS_PER_USER, handler_identity
  relay helpers); assertions unchanged.

(cherry picked from commit 92165a2b097126470e4827327d96d592a253adf1)
This commit is contained in:
host committed 2026-11-03 02:49:26 +00:00
1 parent c900a0f78d
commit 935b530f84
9 files changed
+3439 -3119

No files matched your search

+744
View File
@@ -0,0 +1,744 @@
"""Handler mixin:ProjectMixin。
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
"""
from cloud.auth import ROLES as _MEMBER_ROLES
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.push import (
delete_subscription as _push_delete,
list_subscriptions as _push_list,
)
from cloud.peer_messages import row_get as _row_get
from cloud.github_app import (
list_installations,
)
import re
import sqlite3
import time
import uuid
from cloud.repos import validate_repo_url
from cloud.handler_support import (
MAX_PROJECTS_PER_USER as MAX_PROJECTS_PER_USER,
MAX_PROJECT_ID_LEN as MAX_PROJECT_ID_LEN,
MAX_PROJECT_NAME_LEN as MAX_PROJECT_NAME_LEN,
_auth_store as _auth_store,
_environment_to_dict as _environment_to_dict,
)
class ProjectMixin:
"""ProjectMixin 领域方法(见模块 docstring)。"""
# -- 环境 ----------------------------------------------------------
def _fetch_environment(self, env_id):
conn = self._db()
try:
return conn.execute(
"SELECT * FROM environments WHERE id = ?", (env_id,)
).fetchone()
finally:
conn.close()
def _create_environment(self, user_id):
"""建环境:只记录意图(status=creating),检出由 worker
在下一轮准备(clone + checkout 分支),准备好后 status
变 ready,失败变 failed 并带 error_text——创建本身是同步
返回的记录,准备是异步的,与任务 queued->running 同款语义。
同项目内未归档环境名唯一(409),避免选环境时分不清;
归档过的名字可重用。项目没有 repo_url 时 400:环境就是
仓库的基准检出,没有仓库就没有环境。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
project_id = body.get("project_id")
if not project_id or not isinstance(project_id, str):
self._send_json(400, {"error": "project_id required"})
return
name = body.get("name")
if not name or not isinstance(name, str) or not name.strip():
self._send_json(400, {"error": "name required"})
return
name = name.strip()
if len(name) > 100:
self._send_json(400, {"error": "name too long"})
return
executor = body.get("executor")
if executor is not None and executor not in ("local", "docker"):
self._send_json(400, {
"error": "executor must be local or docker"})
return
try:
branch = _validate_env_branch(body.get("branch"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
now = time.time()
env_id = uuid.uuid4().hex
conn = self._db()
try:
proj = conn.execute(
"SELECT repo_url FROM projects WHERE id = ?",
(project_id,)).fetchone()
if proj is None:
self._send_json(404, {"error": "project not found"})
return
repo_url = _row_get(proj, "repo_url")
if not repo_url:
self._send_json(400, {
"error": "project has no repo_url, "
"environment needs a repo"})
return
existing = conn.execute(
"SELECT id FROM environments WHERE project_id = ?"
" AND name = ? AND status != 'archived'",
(project_id, name)).fetchone()
if existing:
self._send_json(409, {
"error": "environment name already exists"
" in this project"})
return
try:
conn.execute(
"INSERT INTO environments(id, project_id, name,"
" branch, executor, status, error_text, repo_url,"
" created_by, created_at, updated_at)"
" VALUES(?, ?, ?, ?, ?, 'creating', NULL, ?, ?,"
" ?, ?)",
(env_id, project_id, name, branch, executor,
repo_url, user_id, now, now))
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
self._send_json(409, {
"error": "environment name already exists"
" in this project"})
return
row = conn.execute(
"SELECT * FROM environments WHERE id = ?", (env_id,)
).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "environment_created",
env_id)
self._send_json(201, _environment_to_dict(row))
def _list_environments(self, user_id, query):
project_ids = query.get("project_id")
project_id = project_ids[0] if project_ids else None
if not project_id:
self._send_json(400, {"error": "project_id required"})
return
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
include_archived = query.get("include_archived") == ["1"]
conn = self._db()
try:
if include_archived:
rows = conn.execute(
"SELECT * FROM environments WHERE project_id = ?"
" ORDER BY created_at, id",
(project_id,)).fetchall()
else:
rows = conn.execute(
"SELECT * FROM environments WHERE project_id = ?"
" AND status != 'archived'"
" ORDER BY created_at, id",
(project_id,)).fetchall()
finally:
conn.close()
self._send_json(200, {
"environments": [_environment_to_dict(r) for r in rows]})
def _get_environment(self, user_id, env_id):
row = self._fetch_environment(env_id)
if row is None:
self._send_json(404, {"error": "environment not found"})
return
env = _environment_to_dict(row)
err = self._check_project(user_id, env["project_id"], "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._send_json(200, env)
def _archive_environment(self, user_id, env_id):
"""归档环境:已归档时幂等返回 200;有 queued/running
任务正在用它时 409,绝不让进行中的任务失去基准检出。
归档只改状态,不删检出目录(已完成任务的 worktree 独立,
检出留着可查;磁盘清理另行手动做,不在归档里顺手删)。"""
row = self._fetch_environment(env_id)
if row is None:
self._send_json(404, {"error": "environment not found"})
return
env = _environment_to_dict(row)
err = self._check_project(
user_id, env["project_id"], "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
if env["status"] == "archived":
self._send_json(200, env)
return
now = time.time()
conn = self._db()
try:
using = conn.execute(
"SELECT id FROM tasks WHERE environment_id = ?"
" AND status IN ('queued', 'running') LIMIT 1",
(env_id,)).fetchone()
if using:
self._send_json(409, {
"error": "environment is in use by a queued or"
" running task"})
return
conn.execute(
"UPDATE environments SET status = 'archived',"
" updated_at = ? WHERE id = ?",
(now, env_id))
conn.commit()
row = conn.execute(
"SELECT * FROM environments WHERE id = ?", (env_id,)
).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, env["project_id"],
"environment_archived", env_id)
self._send_json(200, _environment_to_dict(row))
# -- 项目 ----------------------------------------------------------
def _create_project(self, user_id):
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
name = body.get("name")
if not name or not isinstance(name, str):
self._send_json(400, {"error": "name required"})
return
if len(name) > MAX_PROJECT_NAME_LEN:
self._send_json(400, {
"error": "project name too long: max %d characters"
% MAX_PROJECT_NAME_LEN})
return
project_id = body.get("id") or uuid.uuid4().hex
if not isinstance(project_id, str):
self._send_json(400, {"error": "id must be a string"})
return
if len(project_id) > MAX_PROJECT_ID_LEN:
self._send_json(400, {
"error": "project id too long: max %d characters"
% MAX_PROJECT_ID_LEN})
return
# 单用户项目数上限:按 projects.owner_id 计(成员身份加入
# 的项目不计入自己的上限,避免“被拉进很多项目就建不了
# 项目”);超限 400 且不建项目。
_count_conn = self._db()
try:
_owned = _count_conn.execute(
"SELECT COUNT(*) FROM projects WHERE owner_id = ?",
(user_id,)).fetchone()[0]
finally:
_count_conn.close()
if _owned >= MAX_PROJECTS_PER_USER:
self._send_json(400, {
"error": "project limit exceeded: max %d projects"
" per user" % MAX_PROJECTS_PER_USER})
return
# repo_url 可选:不传时为 None(老行为不变);传了则与 worker
# 共用 cloud.repos.validate_repo_url 的同一套校验,不合法时
# 400 且不建项目,绝不把带凭证的 URL 存进库。
repo_url = body.get("repo_url")
if repo_url is not None:
try:
repo_url = validate_repo_url(repo_url)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
# auto_pr 可选,默认 0(关闭):只接受 0/1(布尔 True/False
# 按 1/0 计),其它值 400 且不建项目。本轮没有现成的
# PATCH /projects/{id} 路由,auto_pr 只能在创建时指定,
# 建后改它需直接改库或重建项目(见 README)。
auto_pr_raw = body.get("auto_pr", 0)
if isinstance(auto_pr_raw, bool):
auto_pr = 1 if auto_pr_raw else 0
elif isinstance(auto_pr_raw, int) and auto_pr_raw in (0, 1):
auto_pr = auto_pr_raw
else:
self._send_json(400, {"error": "auto_pr must be 0 or 1"})
return
now = time.time()
conn = self._db()
try:
existing = conn.execute(
"SELECT id FROM projects WHERE id = ?", (project_id,)
).fetchone()
if existing:
self._send_json(409, {"error": "project exists"})
return
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at,"
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, ?)",
(project_id, name, user_id, now, repo_url, auto_pr),
)
conn.commit()
project = {
"id": project_id,
"name": name,
"owner_id": user_id,
"created_at": now,
"repo_url": repo_url,
"auto_pr": auto_pr,
}
except (sqlite3.IntegrityError, _PgIntegrityError):
self._send_json(409, {"error": "project exists"})
return
finally:
conn.close()
# AuthStore.create_project 只写成员表(owner 角色),项目记录
# 本身在上面的 projects 表;两者配合才算一个完整项目。
with _auth_store(self.server.db_path) as store:
store.create_project(project_id, user_id)
store.audit(user_id, project_id, "create_project", name)
self._send_json(201, project)
def _update_project(self, user_id, project_id):
"""PATCH /projects/{id}:项目 owner 可改 repo_url(建后补仓
库,解“建项目时没填仓库、之后建环境一直 400”的死结)。
repo_url 缺 key 时 400;null 或空串表示清空;字符串走与
创建时同一套 validate_repo_url 校验(带凭证的 URL 拒绝)。
只改 projects 行,不动已建环境的快照 repo_url——已建环境
仍用建环境时的仓库,新环境才用新值。改完记 update_project
审计。"""
err = self._check_project(user_id, project_id, "manage_project")
if err:
self._send_json(err[0], {"error": err[1]})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
if "repo_url" not in body:
self._send_json(400, {"error": "repo_url required"})
return
repo_url = body.get("repo_url")
if repo_url is not None and repo_url != "":
if not isinstance(repo_url, str):
self._send_json(400, {"error": "repo_url must be a string"})
return
try:
repo_url = validate_repo_url(repo_url)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
else:
repo_url = None
conn = self._db()
try:
row = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects WHERE id = ?",
(project_id,)).fetchone()
if row is None:
self._send_json(404, {"error": "project not found"})
return
conn.execute(
"UPDATE projects SET repo_url = ? WHERE id = ?",
(repo_url, project_id))
conn.commit()
row = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects WHERE id = ?",
(project_id,)).fetchone()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "update_project",
repo_url or "(cleared)")
self._send_json(200, {"project": _project_to_dict(row)})
def _github_repos_list(self, user_id):
"""GET /github/repos:只返回调用者成员项目对应的仓库。
P1 收紧前返回全部安装记录(任一已认证用户可看到全部
安装仓库名,属跨租户信息泄露)。现按 project_members
过滤:只保留调用者是成员的项目的 repo_url 对应的仓库
(clone_url 归一化后比对:去 .git 后缀、比对小写);
调用者无成员项目或项目未填 repo_url 时返回空列表。
手填 URL 的能力不变——没进 App 安装的公开仓库仍可手动填。"""
conn = self._db()
try:
rows = list_installations(conn)
member_ids = {
row["project_id"]
for row in conn.execute(
"SELECT project_id FROM project_members"
" WHERE user_id = ?",
(user_id,)).fetchall()
}
allowed = set()
for row in conn.execute(
"SELECT id, repo_url FROM projects").fetchall():
if row["id"] not in member_ids or not row["repo_url"]:
continue
url = str(row["repo_url"]).strip().lower()
if url.endswith(".git"):
url = url[:-4]
allowed.add(url)
finally:
conn.close()
repos = []
for r in rows:
clone_url = "https://github.com/%s.git" % r["repo_full_name"]
normalized = clone_url.lower()
if normalized.endswith(".git"):
normalized = normalized[:-4]
if normalized not in allowed:
continue
repos.append({
"installation_id": r["installation_id"],
"account_login": r["account_login"],
"repo_full_name": r["repo_full_name"],
"clone_url": clone_url,
})
self._send_json(200, {"repos": repos})
def _list_projects(self, user_id):
conn = self._db()
try:
member_rows = conn.execute(
"SELECT project_id, role FROM project_members"
" WHERE user_id = ?",
(user_id,)).fetchall()
member_ids = {row["project_id"] for row in member_rows}
roles = {row["project_id"]: row["role"]
for row in member_rows}
rows = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects ORDER BY created_at, id"
).fetchall()
finally:
conn.close()
projects = []
for r in rows:
if r["id"] not in member_ids:
continue
# 附加字段 my_role(只增字段,不改已有成功语义):
# 控制台据此按角色禁用重试/合并/提 PR 等按钮;服务端
# 权限仍以每次请求的 check_permission 为准,前端禁用
# 只是防误点,不是鉴权。
item = _project_to_dict(r)
item["my_role"] = roles.get(r["id"])
projects.append(item)
self._send_json(200, {"projects": projects})
# -- 项目成员:邀请 / 列表 / 改角色 -------------------------------
def _can_manage_members(self, user_id, project_id):
"""成员管理权:项目 owner 或全局管理员。
全局管理员(CODEX_GLOBAL_ADMINS)不一定是项目成员,
故不能只用项目内 manage_project 判定;项目 owner 判定
复用 check_permission 的 manage_project(仅 owner)。
"""
if self._is_global_admin(user_id):
return True
with _auth_store(self.server.db_path) as store:
return store.check_permission(
user_id, project_id, "manage_project")
def _set_member_row(self, store, project_id, actor_id, target_id,
role):
"""写成员行并记审计:owner 走 AuthStore.set_member(内含
owner 校验与审计);非成员的全局管理员走直接写 + 同格式
审计(set_member 的 owner 校验会挡住全局管理员,故分流)。
"""
if store.check_permission(actor_id, project_id,
"manage_project"):
store.set_member(project_id, actor_id, target_id, role)
return
store.conn.execute(
"INSERT OR REPLACE INTO project_members"
"(project_id, user_id, role) VALUES(?, ?, ?)",
(project_id, target_id, role))
store.conn.commit()
store.audit(actor_id, project_id, "set_member",
"%s:%s" % (target_id, role))
def _project_members_list(self, user_id, project_id):
"""GET /projects/{id}/members:项目成员(read)或全局
管理员可读。只返回 user_id / username / role,绝不返回
key 哈希等认证字段。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._is_global_admin(user_id):
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
rows = conn.execute(
"SELECT m.user_id, m.role, u.username FROM"
" project_members m LEFT JOIN users u"
" ON u.id = m.user_id WHERE m.project_id = ?"
" ORDER BY u.username, m.user_id",
(project_id,)).fetchall()
finally:
conn.close()
self._send_json(200, {"project_id": project_id, "members": [
{"user_id": r["user_id"], "username": r["username"],
"role": r["role"]} for r in rows]})
def _resolve_invite_target(self, body):
"""邀请目标解析:优先 user_id,其次 username 查 id;
返回 (target_id, error_status, error)。"""
target = body.get("user_id")
username = body.get("username")
if target is not None and not isinstance(target, str):
return None, 400, "user_id must be a string"
if username is not None and not isinstance(username, str):
return None, 400, "username must be a string"
if not target and not username:
return None, 400, "user_id or username required"
conn = self._db()
try:
if target:
row = conn.execute(
"SELECT id FROM users WHERE id = ?",
(target,)).fetchone()
if row is None:
return None, 404, "user not found"
return row["id"], None, None
row = conn.execute(
"SELECT id FROM users WHERE username = ?",
(username,)).fetchone()
if row is None:
return None, 404, "user not found"
return row["id"], None, None
finally:
conn.close()
def _project_member_invite(self, user_id, project_id):
"""POST /projects/{id}/members:限项目 owner 或全局管理
员。已是成员时 409(改角色走 PATCH,不用邀请覆盖);
成功记 set_member 审计。role 缺省 contributor。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._can_manage_members(user_id, project_id):
self._send_json(403, {"error": "forbidden"})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
role = body.get("role") or "contributor"
if role not in _MEMBER_ROLES:
self._send_json(400, {"error": "unknown role"})
return
target, estatus, eerr = self._resolve_invite_target(body)
if eerr:
self._send_json(estatus, {"error": eerr})
return
with _auth_store(self.server.db_path) as store:
existing = store.conn.execute(
"SELECT role FROM project_members"
" WHERE project_id = ? AND user_id = ?",
(project_id, target)).fetchone()
if existing is not None:
self._send_json(409, {"error": "already_member"})
return
self._set_member_row(store, project_id, user_id,
target, role)
self._send_json(201, {"project_id": project_id,
"user_id": target, "role": role})
def _project_member_patch(self, user_id, project_id, target):
"""PATCH /projects/{id}/members/{user_id}:限项目 owner
或全局管理员。唯一 owner 降为非 owner 时 409(无论降
自己还是降他人——否则项目将无 owner 可管);目标非成员
时 404。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._can_manage_members(user_id, project_id):
self._send_json(403, {"error": "forbidden"})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
role = body.get("role")
if role not in _MEMBER_ROLES:
self._send_json(400, {"error": "unknown role"})
return
with _auth_store(self.server.db_path) as store:
cur = store.conn.execute(
"SELECT role FROM project_members"
" WHERE project_id = ? AND user_id = ?",
(project_id, target)).fetchone()
if cur is None:
self._send_json(404, {"error": "member not found"})
return
if cur[0] == "owner" and role != "owner":
owners = store.conn.execute(
"SELECT COUNT(*) FROM project_members"
" WHERE project_id = ? AND role = 'owner'",
(project_id,)).fetchone()[0]
if owners <= 1:
self._send_json(
409,
{"error": "cannot demote the only owner"})
return
self._set_member_row(store, project_id, user_id,
target, role)
self._send_json(200, {"project_id": project_id,
"user_id": target, "role": role})
def _project_member_delete(self, user_id, project_id, target):
"""DELETE /projects/{id}/members/{user_id}:限项目 owner
或全局管理员。删唯一 owner 时 409(否则项目将无 owner
可管);目标非成员时 404。成功记 remove_member 审计。"""
if not self._project_exists(project_id):
self._send_json(404, {"error": "project not found"})
return
if not self._can_manage_members(user_id, project_id):
self._send_json(403, {"error": "forbidden"})
return
with _auth_store(self.server.db_path) as store:
cur = store.conn.execute(
"SELECT role FROM project_members"
" WHERE project_id = ? AND user_id = ?",
(project_id, target)).fetchone()
if cur is None:
self._send_json(404, {"error": "member not found"})
return
if cur[0] == "owner":
owners = store.conn.execute(
"SELECT COUNT(*) FROM project_members"
" WHERE project_id = ? AND role = 'owner'",
(project_id,)).fetchone()[0]
if owners <= 1:
self._send_json(
409,
{"error": "cannot remove the only owner"})
return
self._remove_member_row(store, project_id, user_id, target)
self._send_json(200, {"project_id": project_id,
"user_id": target})
def _remove_member_row(self, store, project_id, actor_id, target_id):
"""删成员行并记审计:owner 走 AuthStore.remove_member
(内含 owner 校验与审计);非成员的全局管理员走直接删
+ 同格式审计(remove_member 的 owner 校验会挡住全局
管理员,故分流,与 _set_member_row 同款)。"""
if store.check_permission(actor_id, project_id,
"manage_project"):
store.remove_member(project_id, actor_id, target_id)
return
store.conn.execute(
"DELETE FROM project_members"
" WHERE project_id = ? AND user_id = ?",
(project_id, target_id))
store.conn.commit()
store.audit(actor_id, project_id, "remove_member", target_id)
# -- 项目子资源:记忆 / 知识库 / 成本 -------------------------------
def _project_exists(self, project_id):
conn = self._db()
try:
row = conn.execute(
"SELECT id FROM projects WHERE id = ?", (project_id,)
).fetchone()
return row is not None
finally:
conn.close()
def _check_project(self, user_id, project_id, action):
"""返回 None 表示通过;否则返回 (status, error) 先行响应。"""
if not self._project_exists(project_id):
return 404, "project not found"
with _auth_store(self.server.db_path) as store:
if not store.check_permission(user_id, project_id, action):
return 403, "forbidden"
return None
def _project_push_subscription_delete(self, user_id, method, project_id, parts):
# 从 _dispatch_project 平移的 push 订阅删除分支:
# 语句原样平移,仅缩进减一格;每条路径都已发响应。
if method != "DELETE":
self._send_json(404, {"error": "not found"})
return
err = self._check_project(user_id, project_id, "create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
subs = _push_list(self.server.db_path, project_id)
mine = [s for s in subs if s["id"] == parts[3]]
if not mine:
self._send_json(404, {"error": "subscription not found"})
return
# 只能删自己的订阅,项目 owner 可删任意(owner 角色检查)。
with _auth_store(self.server.db_path) as auth:
is_owner = auth.check_permission(
user_id, project_id, "manage_project")
if mine[0]["user_id"] != user_id and not is_owner:
self._send_json(403, {"error": "forbidden"})
return
deleted = _push_delete(self.server.db_path,
subscription_id=parts[3])
if deleted:
with _auth_store(self.server.db_path) as store:
store.audit(
user_id, project_id, "push_subscription_deleted",
"subscription_id=%s" % parts[3])
self._send_json(200, {"deleted": bool(deleted)})
return
def _project_to_dict(row):
return {
"id": row["id"],
"name": row["name"],
"owner_id": row["owner_id"],
"created_at": row["created_at"],
# 老库行缺 repo_url 列时 _row_get 给 None,不炸(列本身由
# migrate_projects_table 在 init_db 时补,老行读到也是 None)。
"repo_url": _row_get(row, "repo_url"),
# auto_pr 默认 0(关闭):老行缺列时 None 按 0 计,绝不因
# 迁移就把老项目默认改成自动推。
"auto_pr": _row_get(row, "auto_pr") or 0,
}
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
def _validate_env_branch(branch):
"""环境分支名校验:空/None 表示默认分支;非空时只允许
字母数字与 _ . - /,且不能以 - 开头、不能含 ..、不能以
.lock 结尾、长度不超过 200——git ref 名的保守子集,防
分支名被拼进 shell 或越出目录。返回规范化后的分支或 None,
非法时抛 ValueError(调用方转 400)。"""
if branch is None:
return None
if not isinstance(branch, str):
raise ValueError("branch must be a string")
branch = branch.strip()
if not branch:
return None
if len(branch) > 200 or not _ENV_BRANCH_RE.match(branch) \
or branch.startswith("-") or ".." in branch \
or branch.endswith(".lock"):
raise ValueError("branch is not a valid git branch name")
return branch