119 lines
5.3 KiB
Python
119 lines
5.3 KiB
Python
"""update.py tells every agent once which skills changed, as one newsletter through radio.py."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
UPDATE = REPO_ROOT / "skills" / "kvb-org-update" / "scripts" / "update.py"
|
||
|
|
|
||
|
|
|
||
|
|
class TestOrgUpdate(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
tmp = tempfile.TemporaryDirectory()
|
||
|
|
self.addCleanup(tmp.cleanup)
|
||
|
|
base = Path(tmp.name)
|
||
|
|
self.home = base / "home"
|
||
|
|
self.root = base / "council"
|
||
|
|
self.repo = base / "skills"
|
||
|
|
sessions = self.home / ".claude" / "sessions"
|
||
|
|
sessions.mkdir(parents=True)
|
||
|
|
(sessions / "s1.json").write_text(json.dumps({"sessionId": "s1", "name": "One"}), encoding="utf-8")
|
||
|
|
self.env = {
|
||
|
|
"PATH": "/usr/bin:/bin",
|
||
|
|
"HOME": str(self.home),
|
||
|
|
"AGENTS_COUNCIL_ROOT": str(self.root),
|
||
|
|
"AGENTS_COUNCIL_CONFIG": str(base / "config.json"),
|
||
|
|
"CLAUDE_CODE_SESSION_ID": "s1",
|
||
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
||
|
|
"GIT_AUTHOR_NAME": "t",
|
||
|
|
"GIT_AUTHOR_EMAIL": "t@example.invalid",
|
||
|
|
"GIT_COMMITTER_NAME": "t",
|
||
|
|
"GIT_COMMITTER_EMAIL": "t@example.invalid",
|
||
|
|
}
|
||
|
|
self._git("init", "-q", str(self.repo))
|
||
|
|
self._commit("skills/kvb-a/SKILL.md", "a1", "feat(kvb-a): first")
|
||
|
|
|
||
|
|
def _git(self, *args: str) -> str:
|
||
|
|
cwd = self.repo if self.repo.is_dir() else None
|
||
|
|
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True, check=True).stdout
|
||
|
|
|
||
|
|
def _commit(self, path: str, text: str, subject: str) -> None:
|
||
|
|
target = self.repo / path
|
||
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
target.write_text(text, encoding="utf-8")
|
||
|
|
self._git("add", "-A")
|
||
|
|
self._git("commit", "-q", "-m", subject)
|
||
|
|
|
||
|
|
def _update(self, *args: str) -> subprocess.CompletedProcess:
|
||
|
|
return subprocess.run(
|
||
|
|
[sys.executable, str(UPDATE), "--repo", str(self.repo), *args],
|
||
|
|
env=self.env, capture_output=True, text=True, check=False, timeout=60,
|
||
|
|
)
|
||
|
|
|
||
|
|
def _rows(self) -> list[dict]:
|
||
|
|
news = self.root / "news.jsonl"
|
||
|
|
return [json.loads(line) for line in news.read_text(encoding="utf-8").splitlines()] if news.exists() else []
|
||
|
|
|
||
|
|
def _announced(self) -> str | None:
|
||
|
|
marks = self.root / "skills-announced.json"
|
||
|
|
return json.loads(marks.read_text(encoding="utf-8")).get(str(self.repo.resolve())) if marks.exists() else None
|
||
|
|
|
||
|
|
def test_the_first_run_records_head_and_sends_nothing(self):
|
||
|
|
result = self._update()
|
||
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||
|
|
self.assertEqual(self._rows(), [])
|
||
|
|
self.assertEqual(self._announced(), self._git("rev-parse", "HEAD").strip())
|
||
|
|
|
||
|
|
def test_changed_skills_go_out_once_as_one_newsletter(self):
|
||
|
|
self._update()
|
||
|
|
self._commit("skills/kvb-a/SKILL.md", "a2", "fix(kvb-a): sharper step")
|
||
|
|
self._commit("skills/kvb-b/SKILL.md", "b1", "feat(kvb-b): new skill")
|
||
|
|
self._commit("README.md", "readme", "docs: readme only")
|
||
|
|
before = self._announced()
|
||
|
|
dry = self._update("--dry-run")
|
||
|
|
self.assertEqual(dry.returncode, 0, dry.stderr)
|
||
|
|
self.assertIn("kvb-a (fix(kvb-a): sharper step)", dry.stdout)
|
||
|
|
self.assertIn("kvb-b (feat(kvb-b): new skill)", dry.stdout)
|
||
|
|
self.assertNotIn("readme", dry.stdout)
|
||
|
|
self.assertIn("Re-read each changed SKILL.md before its next use.", dry.stdout)
|
||
|
|
self.assertEqual((self._rows(), self._announced()), ([], before))
|
||
|
|
sent = self._update()
|
||
|
|
self.assertEqual(sent.returncode, 0, sent.stderr)
|
||
|
|
(row,) = self._rows()
|
||
|
|
self.assertEqual((row["class"], row["v"], row["from"], row["to"]), ("newsletter", 2, "One", []))
|
||
|
|
self.assertIn("kvb-a (fix(kvb-a): sharper step), kvb-b (feat(kvb-b): new skill)", row["text"])
|
||
|
|
self.assertEqual(self._announced(), self._git("rev-parse", "HEAD").strip())
|
||
|
|
again = self._update()
|
||
|
|
self.assertEqual(again.returncode, 0, again.stderr)
|
||
|
|
self.assertIn("nothing new", again.stdout)
|
||
|
|
self.assertEqual(len(self._rows()), 1)
|
||
|
|
|
||
|
|
def test_since_names_the_base_and_a_removed_skill_says_so(self):
|
||
|
|
self._commit("skills/kvb-b/SKILL.md", "b1", "feat(kvb-b): new skill")
|
||
|
|
self._git("rm", "-q", "-r", "skills/kvb-a")
|
||
|
|
self._git("commit", "-q", "-m", "refactor: drop kvb-a")
|
||
|
|
result = self._update("--since", "HEAD~2", "--dry-run")
|
||
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||
|
|
self.assertIn("kvb-a (removed), kvb-b (feat(kvb-b): new skill)", result.stdout)
|
||
|
|
|
||
|
|
def test_more_than_three_subjects_are_counted(self):
|
||
|
|
self._update()
|
||
|
|
for n in range(5):
|
||
|
|
self._commit("skills/kvb-a/SKILL.md", f"a{n}", f"fix(kvb-a): step {n}")
|
||
|
|
result = self._update("--dry-run")
|
||
|
|
self.assertIn("kvb-a (fix(kvb-a): step 4; fix(kvb-a): step 3; fix(kvb-a): step 2; +2 more)", result.stdout)
|
||
|
|
|
||
|
|
def test_an_unknown_base_is_a_usage_error(self):
|
||
|
|
result = self._update("--since", "no-such-rev")
|
||
|
|
self.assertEqual(result.returncode, 2)
|
||
|
|
self.assertIn("no-such-rev", result.stderr)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|