diff --git a/assets/branding/sourcebraid-liquid-glass-master.png b/assets/branding/sourcebraid-liquid-glass-master.png new file mode 100644 index 0000000..a3f11c8 Binary files /dev/null and b/assets/branding/sourcebraid-liquid-glass-master.png differ diff --git a/codex-plugin/sourcebraid/assets/sourcebraid-icon.png b/codex-plugin/sourcebraid/assets/sourcebraid-icon.png index 78417ac..e9b0783 100644 Binary files a/codex-plugin/sourcebraid/assets/sourcebraid-icon.png and b/codex-plugin/sourcebraid/assets/sourcebraid-icon.png differ diff --git a/icons/icon-128.png b/icons/icon-128.png index 78417ac..e9b0783 100644 Binary files a/icons/icon-128.png and b/icons/icon-128.png differ diff --git a/icons/icon-16.png b/icons/icon-16.png index a9df009..1a0124f 100644 Binary files a/icons/icon-16.png and b/icons/icon-16.png differ diff --git a/icons/icon-32.png b/icons/icon-32.png index 80565d5..15a5203 100644 Binary files a/icons/icon-32.png and b/icons/icon-32.png differ diff --git a/icons/icon-48.png b/icons/icon-48.png index 2a273ab..36e26d1 100644 Binary files a/icons/icon-48.png and b/icons/icon-48.png differ diff --git a/ios/SourceBraid/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/SourceBraid/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 3ee55f8..4a25c56 100644 Binary files a/ios/SourceBraid/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and b/ios/SourceBraid/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/scripts/build_chrome_package.py b/scripts/build_chrome_package.py new file mode 100644 index 0000000..86fb833 --- /dev/null +++ b/scripts/build_chrome_package.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Build a privacy-safe Chrome Web Store ZIP from an explicit allowlist.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path, PurePosixPath + + +PACKAGE_FILES = ( + ".github/workflows/convert-pdfs.yml", + "background.js", + "capture-utils.js", + "content.js", + "icons/icon-16.png", + "icons/icon-32.png", + "icons/icon-48.png", + "icons/icon-128.png", + "manifest.json", + "popup.css", + "popup.html", + "popup.js", + "requirements-docling.txt", + "scripts/convert_pdfs.py", + "scripts/push_with_retry.py", +) + +VERSION_PATTERN = re.compile(r"^(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*)){0,3}$") + + +class PackageError(RuntimeError): + """Raised when a safe release archive cannot be created.""" + + +def validated_manifest(repository_root: Path) -> dict[str, object]: + manifest_path = repository_root / "manifest.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise PackageError(f"could not read manifest.json: {error}") from error + + if manifest.get("manifest_version") != 3: + raise PackageError("manifest.json must use Manifest V3") + if manifest.get("name") != "SourceBraid": + raise PackageError("manifest.json must identify the extension as SourceBraid") + + version = manifest.get("version") + if not isinstance(version, str) or not VERSION_PATTERN.fullmatch(version): + raise PackageError("manifest.json contains an invalid Chrome extension version") + return manifest + + +def validated_package_files(repository_root: Path) -> list[tuple[Path, str]]: + files: list[tuple[Path, str]] = [] + for archive_name in PACKAGE_FILES: + relative = PurePosixPath(archive_name) + if relative.is_absolute() or ".." in relative.parts: + raise PackageError(f"unsafe package path: {archive_name}") + source = repository_root.joinpath(*relative.parts) + if source.is_symlink(): + raise PackageError(f"refusing to package symlink: {archive_name}") + if not source.is_file(): + raise PackageError(f"required extension file is missing: {archive_name}") + files.append((source, relative.as_posix())) + return files + + +def build_package(repository_root: Path, output_path: Path) -> tuple[str, str]: + manifest = validated_manifest(repository_root) + files = validated_package_files(repository_root) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile( + output_path, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for source, archive_name in files: + info = zipfile.ZipInfo(archive_name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, source.read_bytes()) + + with zipfile.ZipFile(output_path) as archive: + packaged_names = tuple(sorted(archive.namelist())) + expected_names = tuple(sorted(PACKAGE_FILES)) + if packaged_names != expected_names: + output_path.unlink(missing_ok=True) + raise PackageError("release archive does not match the explicit allowlist") + + digest = hashlib.sha256(output_path.read_bytes()).hexdigest() + return str(manifest["version"]), digest + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description="Build the SourceBraid Chrome Web Store ZIP from an explicit allowlist.", + ) + result.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="SourceBraid repository root (defaults to the parent of scripts/).", + ) + result.add_argument( + "--output", + type=Path, + help="Output ZIP path (defaults to dist/sourcebraid-chrome-vVERSION.zip).", + ) + return result + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + repository_root = args.repository_root.resolve() + try: + manifest = validated_manifest(repository_root) + version = str(manifest["version"]) + output = (args.output or repository_root / "dist" / f"sourcebraid-chrome-v{version}.zip").resolve() + version, digest = build_package(repository_root, output) + except PackageError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + print(f"package: {output}") + print(f"version: {version}") + print(f"sha256: {digest}") + print(f"files: {len(PACKAGE_FILES)} (explicit allowlist)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_plugin_package.py b/scripts/build_plugin_package.py new file mode 100644 index 0000000..c86cf0d --- /dev/null +++ b/scripts/build_plugin_package.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Build the public skills-only SourceBraid plugin package.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path, PurePosixPath + + +PLUGIN_FILES = ( + "assets/chrome-capture.png", + "assets/codex-search.png", + "assets/private-markdown-archive.png", + "assets/sourcebraid-icon.png", + "scripts/sourcebraid.py", + "skills/sourcebraid-delete/SKILL.md", + "skills/sourcebraid-delete/agents/openai.yaml", + "skills/sourcebraid-index/SKILL.md", + "skills/sourcebraid-index/agents/openai.yaml", + "skills/sourcebraid-search/SKILL.md", + "skills/sourcebraid-search/agents/openai.yaml", +) + +VERSION_PATTERN = re.compile( + r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$" +) + + +class PluginPackageError(RuntimeError): + """Raised when the public plugin package cannot be built safely.""" + + +def public_manifest(plugin_root: Path) -> dict[str, object]: + manifest_path = plugin_root / ".codex-plugin" / "plugin.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise PluginPackageError(f"could not read plugin manifest: {error}") from error + if manifest.get("name") != "sourcebraid": + raise PluginPackageError("plugin manifest name must be sourcebraid") + version = manifest.get("version") + if not isinstance(version, str) or not VERSION_PATTERN.fullmatch(version): + raise PluginPackageError("plugin manifest contains an invalid semantic version") + if manifest.get("skills") != "./skills/": + raise PluginPackageError("plugin manifest must point skills at ./skills/") + + # The first public release is deliberately skills-only. The local source + # package keeps its bundled stdio MCP server for development and repo use. + manifest.pop("mcpServers", None) + manifest.pop("apps", None) + return manifest + + +def validated_files(plugin_root: Path) -> list[tuple[Path, str]]: + result: list[tuple[Path, str]] = [] + for archive_name in PLUGIN_FILES: + relative = PurePosixPath(archive_name) + source = plugin_root.joinpath(*relative.parts) + if source.is_symlink(): + raise PluginPackageError(f"refusing to package symlink: {archive_name}") + if not source.is_file(): + raise PluginPackageError(f"required plugin file is missing: {archive_name}") + result.append((source, relative.as_posix())) + return result + + +def build_package(plugin_root: Path, output_path: Path) -> tuple[str, str]: + manifest = public_manifest(plugin_root) + files = validated_files(plugin_root) + manifest_bytes = (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile( + output_path, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + entries = [(None, ".codex-plugin/plugin.json", manifest_bytes)] + [ + (source, archive_name, None) for source, archive_name in files + ] + for source, archive_name, generated in entries: + info = zipfile.ZipInfo(archive_name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, generated if generated is not None else source.read_bytes()) + + expected = tuple(sorted((".codex-plugin/plugin.json", *PLUGIN_FILES))) + with zipfile.ZipFile(output_path) as archive: + actual = tuple(sorted(archive.namelist())) + packaged_manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + if actual != expected or "mcpServers" in packaged_manifest or "apps" in packaged_manifest: + output_path.unlink(missing_ok=True) + raise PluginPackageError("public plugin archive failed the skills-only allowlist check") + + digest = hashlib.sha256(output_path.read_bytes()).hexdigest() + return str(manifest["version"]), digest + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description="Build the skills-only SourceBraid package for public plugin submission.", + ) + result.add_argument( + "--plugin-root", + type=Path, + default=Path(__file__).resolve().parents[1] / "codex-plugin" / "sourcebraid", + ) + result.add_argument("--output", type=Path) + return result + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + plugin_root = args.plugin_root.resolve() + try: + manifest = public_manifest(plugin_root) + version = str(manifest["version"]) + safe_version = version.replace("+", "-") + output = ( + args.output + or plugin_root.parents[1] / "dist" / f"sourcebraid-plugin-skills-v{safe_version}.zip" + ).resolve() + version, digest = build_package(plugin_root, output) + except PluginPackageError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + print(f"package: {output}") + print(f"version: {version}") + print("type: skills-only") + print(f"sha256: {digest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/setup_github.py b/scripts/setup_github.py new file mode 100644 index 0000000..504382a --- /dev/null +++ b/scripts/setup_github.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Create and initialize a private GitHub repository for SourceBraid.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.parse import quote + + +SUPPORT_FILES = ( + ".github/workflows/convert-pdfs.yml", + "requirements-docling.txt", + "scripts/convert_pdfs.py", + "scripts/push_with_retry.py", +) + + +class SetupError(RuntimeError): + """Raised when repository setup cannot continue safely.""" + + +@dataclass(frozen=True) +class RepositoryName: + owner: str + name: str + + @property + def slug(self) -> str: + return f"{self.owner}/{self.name}" + + +def parse_repository(value: str) -> RepositoryName: + parts = value.strip().split("/") + if len(parts) != 2 or any(not part or part in {".", ".."} for part in parts): + raise argparse.ArgumentTypeError("repository must use OWNER/NAME") + if any(any(character.isspace() for character in part) for part in parts): + raise argparse.ArgumentTypeError("repository owner and name cannot contain whitespace") + return RepositoryName(*parts) + + +def normalize_root_folder(value: str) -> str: + normalized = PurePosixPath(value.strip()).as_posix().strip("/") + if not normalized or normalized == "." or ".." in PurePosixPath(normalized).parts: + raise argparse.ArgumentTypeError("root folder must be a normalized repository path") + return normalized + + +class GitHubCLI: + def __init__(self, executable: str = "gh") -> None: + self.executable = executable + + def auth_status(self) -> None: + result = subprocess.run( + [self.executable, "auth", "status"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise SetupError(f"GitHub CLI authentication failed: {detail}") + + def api( + self, + method: str, + endpoint: str, + payload: dict[str, Any] | None = None, + *, + allow_not_found: bool = False, + ) -> dict[str, Any] | list[Any] | None: + command = [self.executable, "api", "--method", method, endpoint] + input_text = None + if payload is not None: + command.extend(["--input", "-"]) + input_text = json.dumps(payload) + result = subprocess.run( + command, + input=input_text, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + if allow_not_found and ("HTTP 404" in detail or "Not Found" in detail): + return None + raise SetupError(f"GitHub API {method} {endpoint} failed: {detail}") + if not result.stdout.strip(): + return {} + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise SetupError(f"GitHub API returned invalid JSON for {method} {endpoint}") from error + + +def local_support_files(repository_root: Path, root_folder: str) -> dict[str, bytes]: + files: dict[str, bytes] = {f"{root_folder}/.gitkeep": b""} + for repo_path in SUPPORT_FILES: + source = repository_root.joinpath(*PurePosixPath(repo_path).parts) + if source.is_symlink(): + raise SetupError(f"refusing to upload symlink: {repo_path}") + if not source.is_file(): + raise SetupError(f"required setup file is missing: {repo_path}") + files[repo_path] = source.read_bytes() + return files + + +def content_endpoint(repository: RepositoryName, repo_path: str, branch: str | None = None) -> str: + encoded_path = quote(repo_path, safe="/") + endpoint = f"/repos/{repository.slug}/contents/{encoded_path}" + if branch: + endpoint += f"?ref={quote(branch, safe='')}" + return endpoint + + +def ensure_repository( + client: GitHubCLI, + repository: RepositoryName, + *, + dry_run: bool, +) -> tuple[dict[str, Any] | None, bool]: + endpoint = f"/repos/{repository.slug}" + existing = client.api("GET", endpoint, allow_not_found=True) + if isinstance(existing, dict): + if not existing.get("private", False): + raise SetupError( + f"{repository.slug} is public; refusing to configure a SourceBraid archive there" + ) + return existing, False + + if dry_run: + return None, True + + viewer = client.api("GET", "/user") + if not isinstance(viewer, dict) or not isinstance(viewer.get("login"), str): + raise SetupError("could not determine the authenticated GitHub account") + payload = { + "name": repository.name, + "description": "Private Markdown archive managed by SourceBraid.", + "private": True, + "auto_init": True, + } + if viewer["login"].casefold() == repository.owner.casefold(): + created = client.api("POST", "/user/repos", payload) + else: + created = client.api("POST", f"/orgs/{repository.owner}/repos", payload) + if not isinstance(created, dict): + raise SetupError(f"GitHub did not return the created repository {repository.slug}") + return created, True + + +def configure_actions(client: GitHubCLI, repository: RepositoryName, *, dry_run: bool) -> None: + if dry_run: + return + client.api( + "PUT", + f"/repos/{repository.slug}/actions/permissions", + {"enabled": True, "allowed_actions": "all"}, + ) + + +def upload_support_files( + client: GitHubCLI, + repository: RepositoryName, + branch: str, + files: dict[str, bytes], + *, + dry_run: bool, + update_existing: bool, +) -> tuple[list[str], list[str], list[str]]: + created: list[str] = [] + updated: list[str] = [] + skipped: list[str] = [] + for repo_path, content in files.items(): + endpoint = content_endpoint(repository, repo_path) + existing = client.api( + "GET", + content_endpoint(repository, repo_path, branch), + allow_not_found=True, + ) + existing_sha = existing.get("sha") if isinstance(existing, dict) else None + if existing_sha and not update_existing: + skipped.append(repo_path) + continue + + payload: dict[str, Any] = { + "message": f"Initialize SourceBraid support: {repo_path}", + "content": base64.b64encode(content).decode("ascii"), + "branch": branch, + } + if existing_sha: + payload["sha"] = existing_sha + if not dry_run: + client.api("PUT", endpoint, payload) + if existing_sha: + updated.append(repo_path) + else: + created.append(repo_path) + return created, updated, skipped + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description=( + "Create or initialize a private SourceBraid archive using the authenticated GitHub CLI. " + "Existing files are preserved unless --update-existing is supplied." + ), + ) + result.add_argument("--repo", required=True, type=parse_repository, metavar="OWNER/NAME") + result.add_argument("--branch", default="main") + result.add_argument("--root-folder", default="web-clips", type=normalize_root_folder) + result.add_argument("--dry-run", action="store_true") + result.add_argument( + "--update-existing", + action="store_true", + help="Replace only the known SourceBraid support files when their paths already exist.", + ) + result.add_argument( + "--repository-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Local SourceBraid project root containing the support files.", + ) + return result + + +def print_paths(label: str, paths: list[str]) -> None: + if paths: + print(f"{label}: {', '.join(paths)}") + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + repository_root = args.repository_root.resolve() + client = GitHubCLI() + try: + files = local_support_files(repository_root, args.root_folder) + client.auth_status() + repository_info, repository_created = ensure_repository( + client, + args.repo, + dry_run=args.dry_run, + ) + branch = args.branch + if repository_info and not repository_created: + default_branch = repository_info.get("default_branch") + if branch == "main" and isinstance(default_branch, str) and default_branch: + branch = default_branch + configure_actions(client, args.repo, dry_run=args.dry_run) + created, updated, skipped = upload_support_files( + client, + args.repo, + branch, + files, + dry_run=args.dry_run, + update_existing=args.update_existing, + ) + except SetupError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + mode = "dry run" if args.dry_run else "complete" + print(f"setup: {mode}") + print(f"repository: https://github.com/{args.repo.slug}") + print(f"visibility: private") + print(f"branch: {branch}") + print_paths("would create" if args.dry_run else "created", created) + print_paths("would update" if args.dry_run else "updated", updated) + print_paths("preserved", skipped) + if not args.dry_run: + print("next: create a fine-grained GitHub token restricted to this repository") + print("permission: Contents: Read and write") + print("token URL: https://github.com/settings/personal-access-tokens/new") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_packages.py b/tests/test_release_packages.py new file mode 100644 index 0000000..0f1a2b7 --- /dev/null +++ b/tests/test_release_packages.py @@ -0,0 +1,106 @@ +import importlib.util +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def load_module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +chrome_package = load_module( + "build_chrome_package", + REPOSITORY_ROOT / "scripts" / "build_chrome_package.py", +) +plugin_package = load_module( + "build_plugin_package", + REPOSITORY_ROOT / "scripts" / "build_plugin_package.py", +) + + +class ChromePackageTests(unittest.TestCase): + def test_build_uses_only_the_explicit_allowlist(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = {"manifest_version": 3, "name": "SourceBraid", "version": "1.2.3"} + for relative in chrome_package.PACKAGE_FILES: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"fixture") + (root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (root / "web-clips" / "private.md").parent.mkdir(parents=True) + (root / "web-clips" / "private.md").write_text("secret", encoding="utf-8") + output = root / "dist" / "sourcebraid.zip" + + version, digest = chrome_package.build_package(root, output) + + self.assertEqual(version, "1.2.3") + self.assertEqual(len(digest), 64) + with zipfile.ZipFile(output) as archive: + self.assertEqual(sorted(archive.namelist()), sorted(chrome_package.PACKAGE_FILES)) + self.assertNotIn("web-clips/private.md", archive.namelist()) + + def test_symlink_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative in chrome_package.PACKAGE_FILES: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"fixture") + (root / "manifest.json").write_text( + json.dumps({"manifest_version": 3, "name": "SourceBraid", "version": "1.0.0"}), + encoding="utf-8", + ) + (root / "content.js").unlink() + (root / "content.js").symlink_to(root / "background.js") + + with self.assertRaises(chrome_package.PackageError): + chrome_package.validated_package_files(root) + + +class PluginPackageTests(unittest.TestCase): + def test_public_package_removes_local_mcp_configuration(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest_path = root / ".codex-plugin" / "plugin.json" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text( + json.dumps( + { + "name": "sourcebraid", + "version": "1.0.0", + "description": "fixture", + "skills": "./skills/", + "mcpServers": "./.mcp.json", + } + ), + encoding="utf-8", + ) + for relative in plugin_package.PLUGIN_FILES: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"fixture") + (root / ".mcp.json").write_text('{"private": true}', encoding="utf-8") + output = root / "sourcebraid-plugin.zip" + + version, _digest = plugin_package.build_package(root, output) + + self.assertEqual(version, "1.0.0") + with zipfile.ZipFile(output) as archive: + manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + self.assertNotIn("mcpServers", manifest) + self.assertNotIn(".mcp.json", archive.namelist()) + self.assertIn("scripts/sourcebraid.py", archive.namelist()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_setup_github.py b/tests/test_setup_github.py new file mode 100644 index 0000000..9b7ddb2 --- /dev/null +++ b/tests/test_setup_github.py @@ -0,0 +1,83 @@ +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "setup_github", + REPOSITORY_ROOT / "scripts" / "setup_github.py", +) +setup_github = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = setup_github +SPEC.loader.exec_module(setup_github) + + +class FakeGitHubCLI: + def __init__(self, responses=None): + self.responses = responses or {} + self.calls = [] + + def api(self, method, endpoint, payload=None, *, allow_not_found=False): + self.calls.append((method, endpoint, payload, allow_not_found)) + return self.responses.get((method, endpoint)) + + +class SetupGitHubTests(unittest.TestCase): + def test_parse_repository_requires_owner_and_name(self): + repository = setup_github.parse_repository("octocat/sourcebraid-private") + self.assertEqual(repository.slug, "octocat/sourcebraid-private") + with self.assertRaises(Exception): + setup_github.parse_repository("sourcebraid-private") + + def test_public_repository_is_rejected(self): + repository = setup_github.RepositoryName("octocat", "archive") + client = FakeGitHubCLI({("GET", "/repos/octocat/archive"): {"private": False}}) + with self.assertRaises(setup_github.SetupError): + setup_github.ensure_repository(client, repository, dry_run=False) + + def test_existing_files_are_preserved_by_default(self): + repository = setup_github.RepositoryName("octocat", "archive") + existing_endpoint = "/repos/octocat/archive/contents/scripts/existing.py?ref=main" + client = FakeGitHubCLI({("GET", existing_endpoint): {"sha": "abc123"}}) + + created, updated, skipped = setup_github.upload_support_files( + client, + repository, + "main", + {"scripts/existing.py": b"new", "scripts/new.py": b"new"}, + dry_run=False, + update_existing=False, + ) + + self.assertEqual(created, ["scripts/new.py"]) + self.assertEqual(updated, []) + self.assertEqual(skipped, ["scripts/existing.py"]) + put_paths = [call[1] for call in client.calls if call[0] == "PUT"] + self.assertEqual(put_paths, ["/repos/octocat/archive/contents/scripts/new.py"]) + + def test_support_file_allowlist_never_reads_private_archive(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative in setup_github.SUPPORT_FILES: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("fixture", encoding="utf-8") + private = root / "web-clips" / "private.md" + private.parent.mkdir(parents=True) + private.write_text("secret", encoding="utf-8") + + files = setup_github.local_support_files(root, "web-clips") + + self.assertEqual( + sorted(files), + sorted((*setup_github.SUPPORT_FILES, "web-clips/.gitkeep")), + ) + self.assertNotIn("web-clips/private.md", files) + + +if __name__ == "__main__": + unittest.main()