import os import shutil import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "build_tsl_playbook.py" SYNC_WORKFLOW = ROOT / ".gitea" / "workflows" / "sync-tsl-playbook.yml" class BuildTslPlaybookTests(unittest.TestCase): def test_builds_minimal_tsl_playbook_tree(self): with tempfile.TemporaryDirectory() as tmp_dir: output = Path(tmp_dir) / "tsl-playbook" result = subprocess.run( [sys.executable, str(SCRIPT), "--output", str(output)], capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertEqual( sorted(path.name for path in output.iterdir()), ["AGENTS.md", "docs", "skills"], ) self.assertFalse((output / "playbook.toml").exists()) self.assertFalse((output / ".agents").exists()) self.assertFalse((output / "docs" / "index.md").exists()) self.assertTrue((output / "docs" / "tsl" / "index.md").is_file()) self.assertTrue( (output / "skills" / "tsl-api-reference" / "SKILL.md").is_file() ) self.assertTrue( ( output / "skills" / "tsl-api-reference" / "scripts" / "lookup.py" ).is_file() ) agents_text = (output / "AGENTS.md").read_text(encoding="utf-8") self.assertIn("# TSL Agent Instructions", agents_text) self.assertIn("docs/tsl/index.md", agents_text) self.assertIn("tsl-api-reference", agents_text) self.assertNotIn(".agents/index.md", agents_text) self.assertNotIn(".agents/tsl/index.md", agents_text) source_docs = count_files(ROOT / "docs" / "tsl") output_docs = count_files(output / "docs" / "tsl") self.assertEqual(output_docs, source_docs) source_skill = count_files(ROOT / "skills" / "tsl-api-reference") output_skill = count_files(output / "skills" / "tsl-api-reference") self.assertEqual(output_skill, source_skill) def test_sync_workflow_does_not_remove_entire_target_branch(self): text = SYNC_WORKFLOW.read_text(encoding="utf-8") self.assertNotRegex(text, r"git rm -rf --quiet\s+\.") self.assertIn("generated_paths=(AGENTS.md docs skills)", text) self.assertIn('git add -A "${generated_paths[@]}"', text) self.assertNotIn(".gitea/ci/", text) self.assertNotIn("https://oauth2", text) self.assertNotIn("oauth2:${TOKEN}", text) self.assertNotRegex(text, r"REPO_URL=.*(TOKEN|WORKFLOW)") self.assertNotIn("git remote set-url", text) self.assertIn("GIT_ASKPASS", text) self.assertIn('REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"', text) def test_sync_preserves_files_outside_generated_paths(self): if shutil.which("bash") is None: self.skipTest("bash is required to run sync workflow script") with tempfile.TemporaryDirectory() as tmp_dir: repo = create_source_repo(Path(tmp_dir)) git(repo, "checkout", "--orphan", "tsl-playbook") git(repo, "rm", "-rf", ".") (repo / "README.md").write_text( "manual branch note\n", encoding="utf-8", newline="\n" ) git(repo, "add", "README.md") git(repo, "commit", "-m", "manual target branch note") git(repo, "push", "-u", "origin", "tsl-playbook") git(repo, "checkout", "main") run_sync(repo) readme = run( ["git", "show", "HEAD:README.md"], cwd=repo, check=False, ) self.assertEqual(readme.returncode, 0, msg=readme.stderr) self.assertEqual(readme.stdout, "manual branch note\n") for path in ( "AGENTS.md", "docs/tsl/index.md", "skills/tsl-api-reference/SKILL.md", ): git(repo, "cat-file", "-e", f"HEAD:{path}") def test_sync_creates_new_branch_without_source_files(self): if shutil.which("bash") is None: self.skipTest("bash is required to run sync workflow script") with tempfile.TemporaryDirectory() as tmp_dir: repo = create_source_repo(Path(tmp_dir)) run_sync(repo) for path in ( "AGENTS.md", "docs/tsl/index.md", "skills/tsl-api-reference/SKILL.md", ): git(repo, "cat-file", "-e", f"HEAD:{path}") for path in ( ".gitea/workflows/sync-tsl-playbook.yml", "scripts/build_tsl_playbook.py", "rulesets/tsl/index.md", ): result = run( ["git", "cat-file", "-e", f"HEAD:{path}"], cwd=repo, check=False, ) self.assertNotEqual(result.returncode, 0, msg=f"{path} leaked") def create_source_repo(tmp: Path) -> Path: repo = tmp / "repo" remote = tmp / "remote.git" run(["git", "init", "--bare", str(remote)]) run(["git", "init", str(repo)]) git(repo, "checkout", "-b", "main") git(repo, "config", "user.name", "test") git(repo, "config", "user.email", "test@example.invalid") copy_required_sources(repo) git(repo, "add", ".") git(repo, "commit", "-m", "initial sources") git(repo, "remote", "add", "origin", "../remote.git") git(repo, "push", "-u", "origin", "main") return repo def run_sync(repo: Path) -> None: env = os.environ.copy() env.update( { "REPO_DIR": str(repo), "TARGET_BRANCH": "tsl-playbook", "COMMIT_AUTHOR_NAME": "test", "COMMIT_AUTHOR_EMAIL": "test@example.invalid", } ) with tempfile.NamedTemporaryFile( "w", suffix=".sh", encoding="utf-8", newline="\n", delete=False ) as script_file: script_file.write(extract_sync_workflow_script()) script_path = script_file.name try: result = subprocess.run( ["bash", script_path], cwd=repo, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", ) finally: os.unlink(script_path) if result.returncode != 0: raise AssertionError(result.stderr + result.stdout) def copy_required_sources(repo: Path) -> None: (repo / ".gitea" / "workflows").mkdir(parents=True) shutil.copy2( SYNC_WORKFLOW, repo / ".gitea" / "workflows" / "sync-tsl-playbook.yml" ) (repo / "scripts").mkdir() shutil.copy2(SCRIPT, repo / "scripts" / "build_tsl_playbook.py") (repo / "docs" / "tsl").mkdir(parents=True) (repo / "docs" / "tsl" / "index.md").write_text( "# TSL Index\n", encoding="utf-8", newline="\n" ) skill = repo / "skills" / "tsl-api-reference" (skill / "scripts").mkdir(parents=True) (skill / "SKILL.md").write_text( "---\nname: tsl-api-reference\n---\n", encoding="utf-8", newline="\n" ) (skill / "scripts" / "lookup.py").write_text( "print('lookup')\n", encoding="utf-8", newline="\n" ) (repo / "rulesets" / "tsl").mkdir(parents=True) (repo / "rulesets" / "tsl" / "index.md").write_text( "# TSL Agent Instructions\n", encoding="utf-8", newline="\n" ) def count_files(path: Path) -> int: return sum(1 for item in path.rglob("*") if item.is_file()) def extract_sync_workflow_script() -> str: lines = SYNC_WORKFLOW.read_text(encoding="utf-8").splitlines() in_sync_step = False in_run_block = False script_lines: list[str] = [] for line in lines: if line.startswith(" - name: 📦 Build and publish tsl-playbook"): in_sync_step = True continue if in_sync_step and line.startswith(" - name: "): break if in_sync_step and line == " run: |": in_run_block = True continue if not in_run_block: continue if line.startswith(" "): script_lines.append(line[10:]) continue if line.strip() == "": script_lines.append("") continue break if not script_lines: raise AssertionError("sync workflow run block was not found") return "\n".join(script_lines) + "\n" def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: return run(["git", *args], cwd=repo) def run( args: list[str], cwd: Path | None = None, check: bool = True, ) -> subprocess.CompletedProcess[str]: result = subprocess.run( args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", ) if check and result.returncode != 0: raise AssertionError( f"command failed: {' '.join(args)}\n{result.stderr}{result.stdout}" ) return result if __name__ == "__main__": unittest.main()