POST /tasks/{id}/pr used to push the branch as-is, so uncommitted
worktree changes never reached the PR (empty diff). Now, after all
skip checks pass, the endpoint commits the worktree on host/<task>
(guarded: refuses when the worktree is on another branch), cleans
__pycache__/*.pyc like host.py merge does, and falls back to the
cloud identity when no git identity is configured. Nothing-to-commit
proceeds. Docs corrected: PR comes after status-merge and before
any local git merge (which deletes branch+worktree).
239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""云端自动 PR:默认关闭、显式触发、缺条件时跳过不失败。
|
||
|
||
零第三方依赖,只用 Python 标准库;不改 host.py 的任务逻辑,
|
||
只复用它的规则:host 任务名 = cloud 任务 id 经安全字符过滤
|
||
(见 cloud/sandbox.py LocalExecutor._host_name),分支名是
|
||
host/<host 任务名>,worktree 在 <仓库根>/work/host-worktrees/
|
||
<host 任务名>。
|
||
|
||
触发规则(按顺序,任一步跳过即返回,不推、不改任务状态):
|
||
1. 任务所属项目的 auto_pr 不为 1 -> pr_skipped, auto_pr_disabled
|
||
2. 本地没有对应 worktree -> pr_skipped, no_worktree
|
||
3. worktree 里没有 git remote origin -> pr_skipped, no_remote
|
||
4. 本机没有 gh 二进制 -> pr_skipped, no_gh
|
||
5. gh 未登录(gh auth status 非零) -> pr_skipped,
|
||
gh_not_authenticated
|
||
只有以上全过才提交 worktree 改动、push 分支 + gh pr create;
|
||
push 或 pr create 失败时抛 PrStepError(调用方据此返回 502),
|
||
任务状态保持 merged 不变,绝不把任务标 failed。
|
||
|
||
提交规则(create_task_pr 在全部跳过检查通过后、push 之前做):
|
||
worktree 必须正好在分支 host/<任务名> 上,否则直接失败,
|
||
绝不在别的分支上提交;先清掉 worktree 内的 __pycache__ /
|
||
*.pyc(与 host.py merge 同规则,只动 worktree,不动主仓),
|
||
再 git add -A + commit;nothing to commit 时直接继续。
|
||
身份用环境里的 git 身份(与 host.py merge 一致),缺身份时
|
||
回退到 cloud/cloud@local 再试一次。
|
||
|
||
顺序约束(与云端 merge 接口配套):任务 completed -> 调
|
||
POST /tasks/{id}/merge 改状态 -> 调 POST /tasks/{id}/pr 推分支
|
||
建 PR -> 再在 GitHub 上合 PR 或本地 host.py merge。本地
|
||
host.py merge 成功后会删分支和 worktree,此后调 PR 会跳过
|
||
no_worktree,所以 PR 必须在本地 git merge 之前做。
|
||
|
||
worker 不自动推:自动 PR 只由显式调 POST /tasks/{id}/pr
|
||
触发。默认自动推会把测试任务也推到远端,本模块不做。
|
||
|
||
本模块不存、不打任何密钥:gh 的登录凭证由 gh 自己管
|
||
(gh auth login),push/pr 的命令与错误文本里不出现任何
|
||
token;错误文本只含 git/gh 的原文摘要,不含远端 URL 里的
|
||
userinfo(若 remote URL 含 userinfo,摘要里把它抹掉)。
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
|
||
_SKIP_AUTO_PR = "auto_pr_disabled"
|
||
_SKIP_NO_WORKTREE = "no_worktree"
|
||
_SKIP_NO_REMOTE = "no_remote"
|
||
_SKIP_NO_GH = "no_gh"
|
||
_SKIP_NOT_AUTHED = "gh_not_authenticated"
|
||
|
||
|
||
class PrSkipped(Exception):
|
||
"""跳过:reason 见上列常量,调用方返回 200 + pr_skipped。"""
|
||
|
||
def __init__(self, reason):
|
||
super().__init__(reason)
|
||
self.reason = reason
|
||
|
||
|
||
class PrStepError(Exception):
|
||
"""push / pr create 哪一步失败:step 为 push 或 pr_create。"""
|
||
|
||
def __init__(self, step, message):
|
||
super().__init__(message)
|
||
self.step = step
|
||
self.message = message
|
||
|
||
|
||
def host_name_for_task(task_id):
|
||
"""cloud 任务 id -> host 任务名(与 sandbox._host_name 同规则)。"""
|
||
return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_id))
|
||
|
||
|
||
def default_worktrees_root():
|
||
"""默认 worktree 根:<仓库根>/work/host-worktrees。"""
|
||
here = os.path.dirname(os.path.abspath(__file__))
|
||
return os.path.join(os.path.dirname(here), "work", "host-worktrees")
|
||
|
||
|
||
def resolve_worktrees_root(override=None):
|
||
"""worktree 根优先级:显式参数 > 环境变量 > 默认。"""
|
||
if override:
|
||
return override
|
||
return os.environ.get("CLOUD_HOST_WORKTREES_DIR") or \
|
||
default_worktrees_root()
|
||
|
||
|
||
def _scrub_url(text):
|
||
"""抹掉 URL 里的 userinfo(https://user:pass@host -> https://host),
|
||
防止错误文本把凭证带出去。"""
|
||
if not text:
|
||
return text
|
||
return re.sub(r"(https?://)[^/@\s]+@", r"\1", text)
|
||
|
||
|
||
def _run(cmd, cwd=None, runner=None):
|
||
"""命令唯一入口:runner 供单测注入,返回带 returncode/stdout/
|
||
stderr 的对象;真实路径走 subprocess。"""
|
||
if runner is not None:
|
||
return runner(cmd, cwd)
|
||
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||
|
||
|
||
def _git_remote_url(worktree_dir, git_runner=None):
|
||
r = _run(["git", "-C", worktree_dir, "remote", "get-url", "origin"],
|
||
cwd=worktree_dir, runner=git_runner)
|
||
url = (getattr(r, "stdout", "") or "").strip()
|
||
if getattr(r, "returncode", 1) != 0 or not url:
|
||
return None
|
||
return url
|
||
|
||
|
||
def check_pr_ready(worktree_dir, git_runner=None, gh_which=None,
|
||
gh_runner=None):
|
||
"""按顺序做跳过检查,全过返回 (worktree_dir, branch);任一步
|
||
不过抛 PrSkipped。branch 由调用方按 host 任务名算好传进来
|
||
的是 worktree 检查——本函数只查 worktree 与 remote/gh/登录,
|
||
分支名由 create_task_pr 组装。"""
|
||
if not os.path.isdir(worktree_dir):
|
||
raise PrSkipped(_SKIP_NO_WORKTREE)
|
||
if _git_remote_url(worktree_dir, git_runner) is None:
|
||
raise PrSkipped(_SKIP_NO_REMOTE)
|
||
gh_bin = gh_which if gh_which is not None else shutil.which("gh")
|
||
if not gh_bin:
|
||
raise PrSkipped(_SKIP_NO_GH)
|
||
r = _run(["gh", "auth", "status"], cwd=worktree_dir, runner=gh_runner)
|
||
if getattr(r, "returncode", 1) != 0:
|
||
raise PrSkipped(_SKIP_NOT_AUTHED)
|
||
return True
|
||
|
||
|
||
def _commit_worktree_changes(worktree_dir, branch, task_id,
|
||
git_runner=None):
|
||
"""push 之前把 worktree 的改动提交到分支上。
|
||
|
||
顺序:先确认 worktree 正好在目标分支上(不在就直接失败,
|
||
绝不在别的分支上提交,commit 到 main 里就污染主分支了);
|
||
再清掉 worktree 内的 __pycache__ / *.pyc(可再生的缓存,
|
||
与 host.py merge 同规则);再 git add -A + commit。
|
||
nothing to commit 时直接返回;缺 git 身份时回退到
|
||
cloud/cloud@local 再试一次;其它失败抛 PrStepError(commit)。
|
||
"""
|
||
cur = _run(["git", "-C", worktree_dir, "branch", "--show-current"],
|
||
cwd=worktree_dir, runner=git_runner)
|
||
if (getattr(cur, "stdout", "") or "").strip() != branch:
|
||
raise PrStepError(
|
||
"commit", "worktree 不在分支 %s 上,拒绝提交" % branch)
|
||
for root, dirs, files in os.walk(worktree_dir):
|
||
if ".git" in dirs:
|
||
dirs.remove(".git")
|
||
if "__pycache__" in dirs:
|
||
shutil.rmtree(os.path.join(root, "__pycache__"),
|
||
ignore_errors=True)
|
||
dirs.remove("__pycache__")
|
||
for fn in files:
|
||
if fn.endswith(".pyc"):
|
||
try:
|
||
os.remove(os.path.join(root, fn))
|
||
except OSError:
|
||
pass
|
||
add = _run(["git", "-C", worktree_dir, "add", "-A"],
|
||
cwd=worktree_dir, runner=git_runner)
|
||
if getattr(add, "returncode", 1) != 0:
|
||
raise PrStepError(
|
||
"commit", "git add 失败:%s" % (
|
||
(getattr(add, "stderr", "") or "")
|
||
or (getattr(add, "stdout", "") or "")))
|
||
msg = "cloud: %s" % task_id
|
||
c = _run(["git", "-C", worktree_dir, "commit", "-m", msg],
|
||
cwd=worktree_dir, runner=git_runner)
|
||
if getattr(c, "returncode", 1) == 0:
|
||
return
|
||
out = ((getattr(c, "stdout", "") or "")
|
||
+ (getattr(c, "stderr", "") or ""))
|
||
if "nothing to commit" in out:
|
||
return
|
||
if ("tell me who you are" in out or "user.name" in out
|
||
or "user.email" in out):
|
||
c2 = _run(["git", "-C", worktree_dir,
|
||
"-c", "user.name=cloud", "-c", "user.email=cloud@local",
|
||
"commit", "-m", msg],
|
||
cwd=worktree_dir, runner=git_runner)
|
||
if getattr(c2, "returncode", 1) == 0:
|
||
return
|
||
out2 = ((getattr(c2, "stdout", "") or "")
|
||
+ (getattr(c2, "stderr", "") or ""))
|
||
if "nothing to commit" in out2:
|
||
return
|
||
raise PrStepError(
|
||
"commit", "git commit 失败(已试 cloud 身份):%s" % out2[:300])
|
||
raise PrStepError("commit", "git commit 失败:%s" % out[:300])
|
||
|
||
|
||
def create_task_pr(task_id, auto_pr, worktrees_root=None,
|
||
git_runner=None, gh_runner=None, gh_which=None):
|
||
"""给一个已 merged 的云端任务建 PR。
|
||
|
||
auto_pr 不为 1 时直接抛 PrSkipped(auto_pr_disabled),调用方
|
||
在查 worktree 之前就应先查项目 auto_pr——本函数也再查一次,
|
||
防止直接调用者漏查。成功返回 {pr_url, pr_number, branch}。
|
||
全部跳过检查通过后,先把 worktree 改动提交到分支再 push,
|
||
否则未提交的改动进不了 PR。
|
||
"""
|
||
if auto_pr != 1:
|
||
raise PrSkipped(_SKIP_AUTO_PR)
|
||
host_name = host_name_for_task(task_id)
|
||
worktree_dir = os.path.join(
|
||
resolve_worktrees_root(worktrees_root), host_name)
|
||
branch = "host/" + host_name
|
||
check_pr_ready(worktree_dir, git_runner=git_runner,
|
||
gh_which=gh_which, gh_runner=gh_runner)
|
||
_commit_worktree_changes(worktree_dir, branch, task_id,
|
||
git_runner=git_runner)
|
||
push = _run(["git", "-C", worktree_dir, "push", "origin", branch],
|
||
cwd=worktree_dir, runner=git_runner)
|
||
if getattr(push, "returncode", 1) != 0:
|
||
raise PrStepError(
|
||
"push", "git push origin %s 失败:%s" % (
|
||
branch, _scrub_url(
|
||
(getattr(push, "stderr", "") or "")
|
||
or (getattr(push, "stdout", "") or ""))))
|
||
title = "cloud: %s" % task_id
|
||
r = _run(["gh", "pr", "create", "--title", title,
|
||
"--body", "cloud 任务 %s 的自动 PR。" % task_id,
|
||
"--head", branch],
|
||
cwd=worktree_dir, runner=gh_runner)
|
||
out = (getattr(r, "stdout", "") or "").strip()
|
||
if getattr(r, "returncode", 1) != 0 or not out:
|
||
raise PrStepError(
|
||
"pr_create", "gh pr create 失败:%s" % _scrub_url(
|
||
(getattr(r, "stderr", "") or "") or out))
|
||
pr_url = out.splitlines()[0].strip()
|
||
m = re.search(r"/(\d+)\s*$", pr_url)
|
||
pr_number = int(m.group(1)) if m else None
|
||
return {"pr_url": pr_url, "pr_number": pr_number, "branch": branch}
|