chore: sync pouch skill workflows

This commit is contained in:
2026-10-14 10:58:03 +08:00
parent 57879cffdd
commit 125bfe0c23
16 changed files with 277 additions and 57 deletions
+197
View File
@@ -1,15 +1,44 @@
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from pouch.yaml_io import safe_dump, safe_load
REPO_ROOT = Path(__file__).resolve().parents[1]
CREATE = REPO_ROOT / "skills" / "ship" / "scripts" / "create_ship.py"
VALIDATE = REPO_ROOT / "skills" / "ship" / "scripts" / "validate_ship.py"
APPEND = REPO_ROOT / "skills" / "ship" / "scripts" / "append_evidence.py"
NEXT = REPO_ROOT / "skills" / "ship" / "scripts" / "next_step.py"
FILLED_SPEC = """# 导出按钮
## 要做什么
列表页给登录用户一个导出按钮。
## 不做什么
不改权限模型。
## 可观察验收
- 登录后列表页可见导出按钮
## 怎么验
- 浏览器打开 /list 看到按钮
"""
FAULT_SPEC = FILLED_SPEC + """
## 故障证据
线上列表页没有导出按钮。复现:登录后打开 /list。
"""
def _run(script: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
@@ -155,5 +184,173 @@ class CreateAndValidateTests(unittest.TestCase):
self.assertIn("不存在", appended.stderr)
def _create(root: Path, ship_id: str, kind: str, *, stop_at: str | None = None) -> Path:
args = [
"--project-root",
str(root),
"--ship-id",
ship_id,
"--kind",
kind,
"--title",
"列表页增加导出按钮",
]
if stop_at:
args.extend(["--stop-at", stop_at])
result = _run(CREATE, *args)
if result.returncode != 0:
raise AssertionError(result.stderr)
return root / ".pouch" / "ship" / ship_id
def _next(root: Path, ship_id: str, entry: str) -> dict:
result = _run(
NEXT,
"--project-root",
str(root),
"--ship-id",
ship_id,
"--entry",
entry,
)
if result.returncode != 0:
raise AssertionError(result.stderr or result.stdout)
return json.loads(result.stdout)
def _patch_current(ship_dir: Path, **fields) -> None:
path = ship_dir / "current.yaml"
data = safe_load(path.read_text(encoding="utf-8"))
for key, value in fields.items():
if key == "delivery_state":
data.setdefault("delivery", {})["state"] = value
elif key == "manager":
data["manager"] = value
elif key == "source":
data["source"] = value
elif key == "spec":
data["spec"].update(value)
else:
data[key] = value
path.write_text(safe_dump(data), encoding="utf-8")
def _fill_task(ship_dir: Path) -> None:
path = ship_dir / "tasks.yaml"
data = safe_load(path.read_text(encoding="utf-8"))
data["tasks"][0]["expected"] = ["登录用户看到导出"]
data["tasks"][0]["verification"] = ["打开 /list"]
path.write_text(safe_dump(data), encoding="utf-8")
class NextStepTests(unittest.TestCase):
def test_new_short_ship_writes_spec(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_create(root, "add-export-button", "short")
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "write_spec")
self.assertEqual(doc["actor"], "manager")
def test_filled_short_without_stop_asks_user(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short")
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
_fill_task(ship_dir)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["status"], "ask_user")
def test_short_implement_then_verify(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short", stop_at="verified")
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
_fill_task(ship_dir)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "implement")
self.assertEqual(doc["actor"], "worker")
self.assertIsNone(doc["skill"])
_patch_current(
ship_dir,
manager={"lastStep": "implement", "lastResult": "pass", "inFlight": None},
)
data = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8"))
data["evidence"]["commits"] = [{"sha": "abc1234deadbeef", "role": "head"}]
(ship_dir / "current.yaml").write_text(safe_dump(data), encoding="utf-8")
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "verify")
self.assertEqual(doc["skill"], "tester")
_patch_current(ship_dir, delivery_state="verified")
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["status"], "done")
def test_fault_spec_dispatches_hunt(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short", stop_at="verified")
(ship_dir / "spec.md").write_text(FAULT_SPEC, encoding="utf-8")
_fill_task(ship_dir)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["step"], "hunt")
self.assertEqual(doc["skill"], "hunt")
def test_long_starts_with_specifier_worker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_create(root, "tenant-permissions", "long")
doc = _next(root, "tenant-permissions", "long")
self.assertEqual(doc["step"], "specifier_new")
self.assertEqual(doc["skill"], "specifier")
self.assertEqual(doc["actor"], "worker")
def test_pr_check_then_merge(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "pr-export", "short", stop_at="released")
_patch_current(
ship_dir,
source={"kind": "pr", "ref": "https://git.example.com/app/-/merge_requests/12"},
)
data = safe_load((ship_dir / "current.yaml").read_text(encoding="utf-8"))
data["evidence"]["prs"] = [
{"url": "https://git.example.com/app/-/merge_requests/12", "status": "opened"}
]
(ship_dir / "current.yaml").write_text(safe_dump(data), encoding="utf-8")
doc = _next(root, "pr-export", "pr")
self.assertEqual(doc["step"], "check_pr")
self.assertEqual(doc["skill"], "check")
_patch_current(
ship_dir,
manager={"lastStep": "check_pr", "lastResult": "pass", "inFlight": None},
)
doc = _next(root, "pr-export", "pr")
self.assertEqual(doc["step"], "merge_pr")
self.assertEqual(doc["skill"], "delivery-loop")
def test_failed_worker_blocks_manager(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
ship_dir = _create(root, "add-export-button", "short", stop_at="verified")
(ship_dir / "spec.md").write_text(FILLED_SPEC, encoding="utf-8")
_fill_task(ship_dir)
_patch_current(
ship_dir,
manager={"lastStep": "implement", "lastResult": "fail", "inFlight": None},
)
doc = _next(root, "add-export-button", "short")
self.assertEqual(doc["status"], "blocked")
self.assertIn("不要自己做", doc["summary"])
def test_entry_skills_are_managers(self) -> None:
for name in ("short-ship", "long-ship", "pr-ship"):
text = (REPO_ROOT / "skills" / name / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("开新 agent", text)
self.assertNotIn("读取并执行", text)
if __name__ == "__main__":
unittest.main()