from __future__ import annotations import os import subprocess import tempfile import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts" WAIT_CI = SCRIPTS / "wait_ci.sh" INIT_CI = SCRIPTS / "init_ci_status.sh" TEMPLATES = REPO_ROOT / "skills" / "builder" / "templates" / "ci-status" def git(cwd: Path, *args: str) -> str: result = subprocess.run( ["git", *args], cwd=cwd, text=True, capture_output=True, check=False, env={ **os.environ, "GIT_AUTHOR_NAME": "Test", "GIT_AUTHOR_EMAIL": "test@example.com", "GIT_COMMITTER_NAME": "Test", "GIT_COMMITTER_EMAIL": "test@example.com", }, ) if result.returncode != 0: raise AssertionError(f"git {args} failed: {result.stderr}") return result.stdout.strip() class BuilderCiTests(unittest.TestCase): def make_repo(self, root: Path, origin: str = "https://gitea.example.test/acme/widget.git") -> str: git(root, "init", "-b", "main") git(root, "config", "user.email", "test@example.com") git(root, "config", "user.name", "Test") (root / "README").write_text("x\n", encoding="utf-8") git(root, "add", "README") git(root, "commit", "-m", "init") git(root, "remote", "add", "origin", origin) return git(root, "rev-parse", "HEAD") def write_makefile(self, root: Path, venue: str = "ci") -> None: (root / "makefile.builder").write_text( "BUILD_VENUE ?= " + venue + "\n" "CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh\n" "print-%:\n" "\t@echo $($*)\n" "venue:\n" "\t@echo $(BUILD_VENUE)\n", encoding="utf-8", ) def write_status_script(self, root: Path, body: str, executable: bool = True) -> Path: path = root / ".pouch" / "builder" / "ci-status.sh" path.parent.mkdir(parents=True, exist_ok=True) path.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8") if executable: path.chmod(0o755) return path def fake_command(self, bin_dir: Path, name: str, body: str) -> None: command = bin_dir / name command.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") command.chmod(0o755) def run_wait( self, root: Path, sha: str, bin_dir: Path | None = None, extra_env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: path = os.environ["PATH"] if bin_dir is not None: path = f"{bin_dir}{os.pathsep}{path}" env = { **os.environ, "PATH": path, "BUILDER_CI_POLL_SEC": "0", "BUILDER_CI_APPEAR_SEC": "0", } if extra_env: env.update(extra_env) return subprocess.run( ["bash", str(WAIT_CI), "--sha", sha, "--root", str(root)], cwd=root, env=env, text=True, capture_output=True, check=False, ) def test_init_copies_each_kind_template(self) -> None: for kind in ("tea", "glab", "gh", "curl"): with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temp: root = Path(temp) result = subprocess.run( ["bash", str(INIT_CI), "--root", str(root), "--kind", kind], text=True, capture_output=True, check=False, ) dest = root / ".pouch" / "builder" / "ci-status.sh" self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), str(dest)) self.assertTrue(dest.is_file()) self.assertTrue(os.access(dest, os.X_OK)) self.assertEqual( dest.read_text(encoding="utf-8"), (TEMPLATES / f"{kind}.sh").read_text(encoding="utf-8"), ) def test_init_rejects_unknown_kind(self) -> None: with tempfile.TemporaryDirectory() as temp: result = subprocess.run( ["bash", str(INIT_CI), "--root", temp, "--kind", "jenkins"], text=True, capture_output=True, check=False, ) self.assertEqual(result.returncode, 2) self.assertIn("--kind must be tea, glab, gh, or curl", result.stderr) def test_success_reads_project_script_not_forge_cli(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) self.write_status_script( root, 'echo "STATUS=success ID=12 PIPELINE=https://gitea.example.test/run/12 JOBS=build=success"', ) bin_dir = root / "bin" bin_dir.mkdir() for name in ("curl", "tea", "glab", "gh"): self.fake_command(bin_dir, name, "exit 9") result = self.run_wait(root, sha, bin_dir) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("OK pipeline=https://gitea.example.test/run/12", result.stdout) self.assertIn("id=12", result.stdout) self.assertIn("jobs=build=success", result.stdout) def test_failed_status_is_failure(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) self.write_status_script( root, 'echo "STATUS=failed ID=21 PIPELINE=https://gitea.example.test/run/21 JOBS=test=failure"', ) result = self.run_wait(root, sha) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIn("reason=job_failed", result.stderr) self.assertIn("test=failure", result.stderr) def test_empty_status_is_no_pipeline(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) self.write_status_script(root, 'echo "STATUS=empty"') result = self.run_wait(root, sha) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIn("reason=no_pipeline", result.stderr) def test_missing_script_is_usage_error(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) result = self.run_wait(root, sha) self.assertEqual(result.returncode, 2, result.stdout + result.stderr) self.assertIn("CI status script missing", result.stderr) def test_non_executable_script_is_usage_error(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) self.write_status_script(root, 'echo "STATUS=success"', executable=False) result = self.run_wait(root, sha) self.assertEqual(result.returncode, 2, result.stdout + result.stderr) self.assertIn("not executable", result.stderr) def test_relative_script_outside_builder_dir_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) rogue = root / "ci-status.sh" rogue.write_text('#!/bin/sh\necho STATUS=success\n', encoding="utf-8") rogue.chmod(0o755) result = self.run_wait( root, sha, extra_env={"CI_STATUS_SCRIPT": "ci-status.sh"} ) self.assertEqual(result.returncode, 2, result.stdout + result.stderr) self.assertIn("must be under .pouch/builder/", result.stderr) def test_script_exit_3_is_cannot_query(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) sha = self.make_repo(root) self.write_makefile(root) self.write_status_script(root, 'echo "Error: not logged in" >&2; exit 3') result = self.run_wait(root, sha) self.assertEqual(result.returncode, 3, result.stdout + result.stderr) self.assertIn("CI status script exited 3", result.stderr) self.assertIn("not logged in", result.stderr) if __name__ == "__main__": unittest.main()