pr: commit worktree changes before push; fix merge/pr order docs
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).
This commit is contained in:
1 parent
b984171f27
commit
0b96b2929c
5 files changed
+263
-10
No files matched your search
+10
-3
@@ -758,10 +758,17 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
|
||||
reason=no_worktree;worktree 里没有 git remote origin 时
|
||||
reason=no_remote;本机没有 gh 二进制时 reason=no_gh;gh 未登录
|
||||
(gh auth status 非零)时 reason=gh_not_authenticated;以上全过
|
||||
才 push 分支 + gh pr create,成功后把 pr_url/pr_number 写回任务
|
||||
才先把 worktree 改动提交到分支上(worktree 必须正好在
|
||||
host/<host 任务名> 上,否则直接失败;nothing to commit 时跳过;
|
||||
缺 git 身份时回退 cloud/cloud@local 再试),再 push 分支 +
|
||||
gh pr create,成功后把 pr_url/pr_number 写回任务
|
||||
记录并记审计 pr_created,返回 200 含 pr_url。push 或 pr create
|
||||
失败时返回 502 且错误文本写清是哪一步(push 还是 pr_create),
|
||||
任务状态保持 merged 不变,绝不把任务标 failed。worker 不自动推:
|
||||
失败时返回 502 且错误文本写清是哪一步(commit、push 还是
|
||||
pr_create),
|
||||
任务状态保持 merged 不变,绝不把任务标 failed。顺序:任务
|
||||
completed -> POST /tasks/{id}/merge 改状态 -> POST
|
||||
/tasks/{id}/pr,本地 host.py merge 会删分支和 worktree、须在
|
||||
PR 之后做,此后调 PR 会跳过 no_worktree。worker 不自动推:
|
||||
自动 PR 只由显式调本端点触发,默认自动推会把测试任务也推到远端。
|
||||
projects 表新增 `auto_pr` 列(默认 0,老库幂等补列);
|
||||
`POST /projects` 接受可选 auto_pr(只接受 0/1,非法值 400);
|
||||
|
||||
+83
-3
@@ -13,9 +13,23 @@ host/<host 任务名>,worktree 在 <仓库根>/work/host-worktrees/
|
||||
4. 本机没有 gh 二进制 -> pr_skipped, no_gh
|
||||
5. gh 未登录(gh auth status 非零) -> pr_skipped,
|
||||
gh_not_authenticated
|
||||
只有以上全过才 push 分支 + gh pr create;push 或 pr create
|
||||
失败时抛 PrStepError(调用方据此返回 502),任务状态保持
|
||||
merged 不变,绝不把任务标 failed。
|
||||
只有以上全过才提交 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
|
||||
触发。默认自动推会把测试任务也推到远端,本模块不做。
|
||||
@@ -118,6 +132,68 @@ def check_pr_ready(worktree_dir, git_runner=None, gh_which=None,
|
||||
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。
|
||||
@@ -125,6 +201,8 @@ def create_task_pr(task_id, auto_pr, worktrees_root=None,
|
||||
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)
|
||||
@@ -134,6 +212,8 @@ def create_task_pr(task_id, auto_pr, worktrees_root=None,
|
||||
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:
|
||||
|
||||
+6
-4
@@ -1279,10 +1279,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
def _merge_task(self, user_id, task_id):
|
||||
"""把云端任务标记为已合并:只补状态,不执行合并本身。
|
||||
|
||||
合并本身仍由 host.py merge(本地或 Docker 容器内)执行,
|
||||
成功后再调本接口同步云端状态——两者分离是故意的:合并
|
||||
是要人看 diff 才点的操作,云端不该在任务 completed 时
|
||||
自动把主分支改了。规则:任务必须存在(404)、调用方
|
||||
本接口不做 git 合并。自动 PR 的正确顺序是:任务
|
||||
completed -> 调本接口改状态 -> 调 POST /tasks/{id}/pr
|
||||
推分支建 PR -> 再在 GitHub 上合 PR 或本地 host.py merge。
|
||||
本地 host.py merge 成功后会删分支和 worktree,此后调
|
||||
PR 会跳过 no_worktree,所以 PR 必须在本地 git merge
|
||||
之前做,而不是之后。规则:任务必须存在(404)、调用方
|
||||
必须是项目 maintainer 以上(approve_merge,403)、任务
|
||||
必须是 completed(其它状态一律 409,含已 merged 的重复
|
||||
调用,避免二次合并混淆)。成功后写审计 merge_task。
|
||||
|
||||
@@ -140,6 +140,12 @@ class _ServerCase(unittest.TestCase):
|
||||
if remote_fail:
|
||||
return _result(1, "", "no remote")
|
||||
return _result(0, "https://example.com/repo.git\n")
|
||||
if "branch" in cmd:
|
||||
# 假 worktree 正好在自己的 host/<任务名> 分支上:
|
||||
# worktree 目录名就是任务 id(见 _worktree)。
|
||||
return _result(0, "host/%s\n" % os.path.basename(cwd or ""))
|
||||
if "add" in cmd or "commit" in cmd:
|
||||
return _result(0, "")
|
||||
if "push" in cmd:
|
||||
if push_fail:
|
||||
return _result(1, "", "push rejected")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""pr.py 提交步骤单测:push 之前先把 worktree 改动提交到分支。
|
||||
|
||||
只用标准库,不联网、不跑真实 git/gh:git/gh 一律用假 runner
|
||||
注入;worktree 用真实临时目录,只为验证 __pycache__ / *.pyc
|
||||
清理(可再生的缓存)真的删、普通文件不动。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
from cloud.pr import PrStepError, create_task_pr
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.pr import PrStepError, create_task_pr
|
||||
|
||||
|
||||
def _result(returncode=0, stdout="", stderr=""):
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout,
|
||||
stderr=stderr)
|
||||
|
||||
|
||||
class _Rec:
|
||||
"""记录命令的假 runner:git/gh 分开注入,commit 行为可配。"""
|
||||
|
||||
def __init__(self, branch="host/t1", commit="ok"):
|
||||
self.calls = []
|
||||
self.branch = branch
|
||||
self.commit = commit # ok | nothing | fail | no-identity | add-fail
|
||||
|
||||
def git(self, cmd, cwd):
|
||||
self.calls.append(list(cmd))
|
||||
if "remote" in cmd:
|
||||
return _result(0, "https://example.com/repo.git\n")
|
||||
if "branch" in cmd:
|
||||
return _result(0, self.branch + "\n")
|
||||
if "add" in cmd:
|
||||
if self.commit == "add-fail":
|
||||
return _result(1, "", "add failed")
|
||||
return _result(0, "")
|
||||
if "commit" in cmd:
|
||||
if "-c" in cmd:
|
||||
return _result(0, "")
|
||||
if self.commit == "nothing":
|
||||
return _result(1, "",
|
||||
"nothing to commit, working tree clean")
|
||||
if self.commit == "fail":
|
||||
return _result(1, "", "fatal: commit exploded")
|
||||
if self.commit == "no-identity":
|
||||
return _result(1, "",
|
||||
"Please tell me who you are")
|
||||
return _result(0, "")
|
||||
if "push" in cmd:
|
||||
return _result(0, "")
|
||||
return _result(0, "")
|
||||
|
||||
def gh(self, cmd, cwd):
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["gh", "auth"]:
|
||||
return _result(0, "", "")
|
||||
if cmd[:2] == ["gh", "pr"]:
|
||||
return _result(0, "https://example.com/repo/pull/7\n")
|
||||
return _result(0, "")
|
||||
|
||||
def kinds(self):
|
||||
"""git 命令按出现顺序归类:branch/add/commit/push。"""
|
||||
out = []
|
||||
for cmd in self.calls:
|
||||
if cmd[:1] == ["git"]:
|
||||
for k in ("branch", "add", "commit", "push"):
|
||||
if k in cmd:
|
||||
out.append(k)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
class PrCommitTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.wt = os.path.join(self.tmp.name, "t1")
|
||||
os.makedirs(self.wt)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _run(self, rec):
|
||||
return create_task_pr(
|
||||
"t1", 1, worktrees_root=self.tmp.name,
|
||||
git_runner=rec.git, gh_runner=rec.gh, gh_which="gh")
|
||||
|
||||
def test_commit_before_push_and_pycache_cleaned(self):
|
||||
with open(os.path.join(self.wt, "file.txt"), "w") as f:
|
||||
f.write("x")
|
||||
cache = os.path.join(self.wt, "__pycache__")
|
||||
os.makedirs(cache)
|
||||
with open(os.path.join(cache, "x.pyc"), "wb") as f:
|
||||
f.write(b"\0")
|
||||
with open(os.path.join(self.wt, "stale.pyc"), "wb") as f:
|
||||
f.write(b"\0")
|
||||
rec = _Rec()
|
||||
res = self._run(rec)
|
||||
self.assertEqual(res["pr_url"], "https://example.com/repo/pull/7")
|
||||
self.assertEqual(res["branch"], "host/t1")
|
||||
kinds = rec.kinds()
|
||||
self.assertLess(kinds.index("branch"), kinds.index("add"))
|
||||
self.assertLess(kinds.index("add"), kinds.index("commit"))
|
||||
self.assertLess(kinds.index("commit"), kinds.index("push"))
|
||||
commit_cmd = [c for c in rec.calls if "commit" in c][0]
|
||||
self.assertIn("cloud: t1", commit_cmd)
|
||||
self.assertFalse(os.path.exists(cache))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.wt, "stale.pyc")))
|
||||
self.assertTrue(os.path.exists(os.path.join(self.wt, "file.txt")))
|
||||
|
||||
def test_nothing_to_commit_proceeds_to_push(self):
|
||||
rec = _Rec(commit="nothing")
|
||||
res = self._run(rec)
|
||||
self.assertEqual(res["pr_number"], 7)
|
||||
self.assertIn("push", rec.kinds())
|
||||
|
||||
def test_wrong_branch_fails_commit_without_push(self):
|
||||
rec = _Rec(branch="main")
|
||||
with self.assertRaises(PrStepError) as ctx:
|
||||
self._run(rec)
|
||||
self.assertEqual(ctx.exception.step, "commit")
|
||||
self.assertNotIn("push", rec.kinds())
|
||||
self.assertNotIn("commit", rec.kinds())
|
||||
|
||||
def test_commit_failure_is_commit_step_without_push(self):
|
||||
rec = _Rec(commit="fail")
|
||||
with self.assertRaises(PrStepError) as ctx:
|
||||
self._run(rec)
|
||||
self.assertEqual(ctx.exception.step, "commit")
|
||||
self.assertNotIn("push", rec.kinds())
|
||||
|
||||
def test_add_failure_is_commit_step_without_push(self):
|
||||
rec = _Rec(commit="add-fail")
|
||||
with self.assertRaises(PrStepError) as ctx:
|
||||
self._run(rec)
|
||||
self.assertEqual(ctx.exception.step, "commit")
|
||||
self.assertNotIn("push", rec.kinds())
|
||||
|
||||
def test_identity_fallback_retries_with_cloud_identity(self):
|
||||
rec = _Rec(commit="no-identity")
|
||||
res = self._run(rec)
|
||||
self.assertEqual(res["pr_number"], 7)
|
||||
commits = [c for c in rec.calls if "commit" in c]
|
||||
self.assertEqual(len(commits), 2)
|
||||
self.assertIn("-c", commits[1])
|
||||
self.assertIn("user.name=cloud", commits[1])
|
||||
self.assertIn("push", rec.kinds())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user