Files
codex-app-server/cloud/handler_project.py
host 935b530f84 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)
2026-11-03 02:49:26 +00:00

745 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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