diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml new file mode 100644 index 0000000..9a0c0ed --- /dev/null +++ b/.github/workflows/test-actions.yml @@ -0,0 +1,59 @@ +name: Test composite actions + +on: + push: + branches: ["master"] + paths: + - "actions/sibling-refs/**" + - ".github/workflows/test-actions.yml" + pull_request: + paths: + - "actions/sibling-refs/**" + - ".github/workflows/test-actions.yml" + +jobs: + sibling-refs-unit: + name: Test sibling ref resolver units + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v6 + - run: python -m unittest discover actions/sibling-refs/tests -v + + sibling-refs-integration: + name: Test sibling ref action (${{ matrix.os }}, ${{ matrix.scenario }}) + if: github.event_name == 'pull_request' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2022] + scenario: [selected, normal] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v6 + - name: Prepare local git and API fixtures + run: >- + python actions/sibling-refs/tests/integration.py prepare + --root "$RUNNER_TEMP/sibling-refs-test" + --github-env "$GITHUB_ENV" + - name: Resolve sibling reference + uses: ./actions/sibling-refs + with: + project-directory: ${{ env.SIBLING_REFS_TEST_PROJECT }} + pull-request-body: >- + ${{ matrix.scenario == 'selected' && 'sync: angr/sibling-ref-fixture#7' || 'No sibling references' }} + - name: Lock, sync, and verify selected commit + run: >- + python actions/sibling-refs/tests/integration.py verify + --root "$SIBLING_REFS_TEST_ROOT" + --scenario "${{ matrix.scenario }}" diff --git a/actions/sibling-refs/action.yml b/actions/sibling-refs/action.yml new file mode 100644 index 0000000..6aad6f7 --- /dev/null +++ b/actions/sibling-refs/action.yml @@ -0,0 +1,31 @@ +name: Resolve sibling pull request refs for uv +description: >- + Configure uv to use open sibling pull requests referenced by the current pull + request body. The project must declare each sibling in tool.uv.sources. +inputs: + project-directory: + description: Directory containing the project's pyproject.toml. + default: . + required: false + pull-request-body: + description: Pull request body to scan. Defaults to the current event's body. + default: "" + required: false +runs: + using: composite + steps: + - name: Resolve sibling refs + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_BODY: ${{ inputs.pull-request-body || github.event.pull_request.body }} + PROJECT_DIRECTORY: ${{ inputs.project-directory }} + run: | + python_command=python3 + if ! command -v "$python_command" >/dev/null 2>&1; then + python_command=python + fi + "$python_command" "$GITHUB_ACTION_PATH/resolve.py" \ + --project-directory "$PROJECT_DIRECTORY" \ + --output "$RUNNER_TEMP/angr-sibling-refs.toml" \ + --github-env "$GITHUB_ENV" diff --git a/actions/sibling-refs/resolve.py b/actions/sibling-refs/resolve.py new file mode 100644 index 0000000..13aceb3 --- /dev/null +++ b/actions/sibling-refs/resolve.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +from pathlib import Path +import re +import sys +import tomllib +from typing import Callable, Iterator, NamedTuple +import urllib.error +import urllib.parse +import urllib.request + + +GITHUB_API_URL = "https://api.github.com" + + +class GitSource(NamedTuple): + package: str + url: str + + +class Override(NamedTuple): + package: str + requirement: str + + +def parse_references(body: str) -> Iterator[tuple[str, int]]: + """Yield GitHub repository and pull request pairs in body order.""" + for word in body.replace("(", " ").replace(")", " ").split(): + word = word.strip(",;") + if "#" in word: + target_repo_name, target_pull_name = word.split("#", 1) + elif "github.com" in word and "pull/" in word: + parts = word.split("/")[-4:] + if len(parts) != 4: + continue + owner, name, pull_component, target_pull_name = parts + if pull_component != "pull": + continue + target_repo_name = f"{owner}/{name}" + else: + continue + + if target_repo_name.count("/") == 1 and target_pull_name.isdigit(): + yield target_repo_name.lower(), int(target_pull_name) + + +def github_repository(url: str) -> str | None: + """Return owner/repository for a GitHub git URL.""" + if url.startswith("git+"): + url = url[4:] + + match = re.fullmatch( + r"https?://github\.com/(?P[A-Za-z0-9-]+)/(?P[A-Za-z0-9_.-]+)", + url, + flags=re.IGNORECASE, + ) + if match is None: + return None + owner = match.group("owner") + repository = match.group("repository") + if repository.endswith(".git"): + repository = repository[:-4] + if not owner or not repository: + return None + return f"{owner}/{repository}".lower() + + +def load_git_sources(project_directory: Path) -> dict[str, list[GitSource]]: + with (project_directory / "pyproject.toml").open("rb") as pyproject: + data = tomllib.load(pyproject) + + source_table = data.get("tool", {}).get("uv", {}).get("sources", {}) + result: dict[str, list[GitSource]] = {} + for package, value in source_table.items(): + if isinstance(value, list): + print(f"Skipping {package}: conditional source lists are not supported", file=sys.stderr) + continue + if not isinstance(value, dict) or not isinstance(value.get("git"), str): + continue + unsupported_keys = set(value) - {"git", "branch", "tag", "rev"} + if unsupported_keys: + keys = ", ".join(sorted(unsupported_keys)) + print(f"Skipping {package}: unsupported git source settings: {keys}", file=sys.stderr) + continue + + url = value["git"] + if not url.startswith(("https://", "http://", "git+https://", "git+http://")): + print(f"Skipping {package}: only HTTP(S) git source URLs are supported", file=sys.stderr) + continue + repository = github_repository(url) + if repository is None: + print(f"Skipping {package}: git source is not a simple GitHub repository URL", file=sys.stderr) + continue + result.setdefault(repository, []).append(GitSource(package, url)) + return result + + +def pull_request_state(api_url: str, repository: str, number: int, token: str | None) -> str | None: + request = urllib.request.Request( + f"{api_url.rstrip('/')}/repos/{repository}/pulls/{number}", + headers={"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}, + ) + if token: + request.add_header("Authorization", f"Bearer {token}") + + try: + with urllib.request.urlopen(request) as response: + data = json.load(response) + except (OSError, urllib.error.HTTPError, urllib.error.URLError, ValueError) as error: + print(f"Could not resolve {repository}#{number}: {error}", file=sys.stderr) + return None + state = data.get("state") + return state if isinstance(state, str) else None + + +def github_api_configuration() -> tuple[str, str | None]: + test_api_url = os.environ.get("SIBLING_REFS_TEST_API_URL") + if test_api_url is not None: + parsed = urllib.parse.urlparse(test_api_url) + if ( + parsed.scheme.lower() != "file" + or parsed.netloc.lower() not in ("", "localhost") + or not parsed.path + or parsed.params + or parsed.query + or parsed.fragment + ): + raise ValueError("SIBLING_REFS_TEST_API_URL must be a local file: URL") + return test_api_url, None + + return os.environ.get("GITHUB_API_URL") or GITHUB_API_URL, os.environ.get("GH_TOKEN") + + +def resolve_overrides( + body: str, + sources: dict[str, list[GitSource]], + state_lookup: Callable[[str, int], str | None], +) -> list[Override]: + overrides: list[Override] = [] + resolved_repositories: set[str] = set() + resolved_packages: set[str] = set() + for repository, number in parse_references(body): + if repository in resolved_repositories or repository not in sources: + continue + + state = state_lookup(repository, number) + if state != "open": + print(f"{repository}#{number} is {state or 'unavailable'}, so it is not used") + continue + + ref = f"refs/pull/{number}/head" + for source in sources[repository]: + normalized_package = source.package.lower() + if normalized_package in resolved_packages: + continue + url = source.url if source.url.startswith("git+") else f"git+{source.url}" + overrides.append(Override(source.package, f"{source.package} @ {url}@{ref}")) + resolved_packages.add(normalized_package) + resolved_repositories.add(repository) + return overrides + + +def write_config(path: Path, overrides: list[Override]) -> None: + lines = ["no-sources-package = ["] + lines.extend(f" {json.dumps(override.package)}," for override in overrides) + lines.append("]") + lines.append("upgrade-package = [") + lines.extend(f" {json.dumps(override.requirement)}," for override in overrides) + lines.extend(("]", "")) + path.write_text("\n".join(lines), encoding="utf-8") + + +def append_github_env(path: Path, config_path: Path) -> None: + with path.open("a", encoding="utf-8") as github_env: + github_env.write(f"UV_CONFIG_FILE={config_path}\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--project-directory", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--github-env", type=Path, required=True) + args = parser.parse_args() + + sources = load_git_sources(args.project_directory) + try: + api_url, token = github_api_configuration() + except ValueError as error: + print(f"Invalid test API override: {error}", file=sys.stderr) + return 2 + overrides = resolve_overrides( + os.environ.get("PR_BODY", ""), + sources, + lambda repository, number: pull_request_state(api_url, repository, number, token), + ) + if not overrides: + print("No open sibling pull requests were selected; uv will use the project's normal sources") + return 0 + + args.output.parent.mkdir(parents=True, exist_ok=True) + write_config(args.output, overrides) + append_github_env(args.github_env, args.output) + for override in overrides: + print(f"Using {override.requirement}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/actions/sibling-refs/tests/integration.py b/actions/sibling-refs/tests/integration.py new file mode 100644 index 0000000..67bc4a4 --- /dev/null +++ b/actions/sibling-refs/tests/integration.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + + +REPOSITORY_URL = "https://github.com/angr/sibling-ref-fixture.git" +PULL_NUMBER = 7 + + +def run(command: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> str: + result = subprocess.run(command, cwd=cwd, env=env, check=False, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"Command failed ({result.returncode}): {' '.join(command)}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result.stdout.strip() + + +def write(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + + +def create_fixture_repository(path: Path) -> tuple[str, str]: + path.mkdir(parents=True) + run(["git", "init", "--initial-branch=master"], cwd=path) + run(["git", "config", "user.name", "ci-settings test"], cwd=path) + run(["git", "config", "user.email", "ci-settings@example.invalid"], cwd=path) + write( + path / "pyproject.toml", + """[project] +name = "sibling-ref-fixture" +version = "0.0.0" + +[build-system] +requires = ["setuptools==80.9.0"] +build-backend = "setuptools.build_meta" +""", + ) + module = path / "sibling_ref_fixture" / "__init__.py" + write(module, 'SELECTED_REF = "master"\n') + run(["git", "add", "pyproject.toml", "sibling_ref_fixture/__init__.py"], cwd=path) + run(["git", "commit", "-m", "master fixture"], cwd=path) + master_commit = run(["git", "rev-parse", "HEAD"], cwd=path) + + write(module, 'SELECTED_REF = "pull"\n') + run(["git", "add", "sibling_ref_fixture/__init__.py"], cwd=path) + run(["git", "commit", "-m", "pull request fixture"], cwd=path) + pull_commit = run(["git", "rev-parse", "HEAD"], cwd=path) + run(["git", "update-ref", f"refs/pull/{PULL_NUMBER}/head", pull_commit], cwd=path) + run(["git", "reset", "--hard", master_commit], cwd=path) + return master_commit, pull_commit + + +def create_project(path: Path) -> None: + path.mkdir(parents=True) + write( + path / "pyproject.toml", + f"""[project] +name = "sibling-ref-consumer" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = ["sibling-ref-fixture"] + +[dependency-groups] +dev = ["typing-extensions==4.15.0"] + +[tool.uv] +default-groups = [] + +[tool.uv.sources] +sibling-ref-fixture = {{ git = "{REPOSITORY_URL}", branch = "master" }} +""", + ) + + +def append_github_env(path: Path, values: dict[str, str]) -> None: + with path.open("a", encoding="utf-8") as github_env: + for name, value in values.items(): + github_env.write(f"{name}={value}\n") + + +def integration_environment(root: Path, project: Path, api_root: Path, git_config: Path) -> dict[str, str]: + return { + "SIBLING_REFS_TEST_ROOT": str(root), + "SIBLING_REFS_TEST_PROJECT": str(project), + "SIBLING_REFS_TEST_API_URL": api_root.as_uri(), + "GIT_CONFIG_GLOBAL": str(git_config), + "UV_CACHE_DIR": str(root / "uv-cache"), + } + + +def prepare(root: Path, github_env: Path | None) -> None: + if root.exists(): + raise FileExistsError(f"Refusing to replace existing integration directory: {root}") + root.mkdir(parents=True) + fixture_repository = root / "sibling-repository" + master_commit, pull_commit = create_fixture_repository(fixture_repository) + project = root / "project" + create_project(project) + + api_root = root / "github-api" + write( + api_root / "repos" / "angr" / "sibling-ref-fixture" / "pulls" / str(PULL_NUMBER), + json.dumps({"state": "open"}), + ) + git_config = root / "gitconfig" + run(["git", "config", "--file", str(git_config), "protocol.file.allow", "always"]) + run( + [ + "git", + "config", + "--file", + str(git_config), + f"url.{fixture_repository.as_uri()}.insteadOf", + REPOSITORY_URL, + ] + ) + environment_values = integration_environment(root, project, api_root, git_config) + environment = os.environ.copy() + environment.update(environment_values) + run(["uv", "lock", "--python", sys.executable], cwd=project, env=environment) + seeded_lock = (project / "uv.lock").read_text(encoding="utf-8") + if master_commit not in seeded_lock: + raise AssertionError(f"Seeded uv.lock does not contain master commit {master_commit}") + if pull_commit in seeded_lock: + raise AssertionError(f"Seeded uv.lock unexpectedly contains pull commit {pull_commit}") + state = { + "master_commit": master_commit, + "pull_commit": pull_commit, + "pull_number": PULL_NUMBER, + "project": str(project), + "api_url": api_root.as_uri(), + "git_config": str(git_config), + "seeded_lock_sha256": hashlib.sha256(seeded_lock.encode()).hexdigest(), + } + write(root / "state.json", json.dumps(state)) + if github_env is not None: + append_github_env(github_env, environment_values) + + +def verify(root: Path, scenario: str) -> None: + state = json.loads((root / "state.json").read_text(encoding="utf-8")) + project = Path(state["project"]) + lock_path = project / "uv.lock" + if not lock_path.is_file(): + raise AssertionError("The prepared project no longer has its seeded uv.lock") + seeded_lock = lock_path.read_text(encoding="utf-8") + if hashlib.sha256(seeded_lock.encode()).hexdigest() != state["seeded_lock_sha256"]: + raise AssertionError("uv.lock changed before the selected-ref lock step") + if state["master_commit"] not in seeded_lock: + raise AssertionError("The prepared uv.lock no longer contains the master commit") + if state["pull_commit"] in seeded_lock: + raise AssertionError("The prepared uv.lock already contains the pull commit") + + run(["uv", "lock", "--python", sys.executable], cwd=project) + run(["uv", "sync", "--locked", "--python", sys.executable], cwd=project) + + expected_name = "pull" if scenario == "selected" else "master" + expected_commit = state[f"{expected_name}_commit"] + lock = lock_path.read_text(encoding="utf-8") + if expected_commit not in lock: + raise AssertionError(f"uv.lock does not contain expected {expected_name} commit {expected_commit}") + if scenario == "selected": + if lock == seeded_lock: + raise AssertionError("The selected-ref lock step did not update the seeded uv.lock") + if state["master_commit"] in lock: + raise AssertionError("The selected-ref uv.lock still contains the master commit") + elif lock != seeded_lock: + raise AssertionError("The normal lock step changed the seeded master uv.lock") + config_file = os.environ.get("UV_CONFIG_FILE") + if scenario == "selected": + if config_file is None: + raise AssertionError("The selected-ref action did not export UV_CONFIG_FILE") + config = Path(config_file).read_text(encoding="utf-8") + if f"@refs/pull/{state['pull_number']}/head" not in config: + raise AssertionError("The selected-ref config does not name the pull request ref") + elif config_file is not None: + raise AssertionError("The no-reference action unexpectedly exported a config file") + + python = project / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + selected_ref = run([str(python), "-c", "import sibling_ref_fixture; print(sibling_ref_fixture.SELECTED_REF)"]) + if selected_ref != expected_name: + raise AssertionError(f"Expected {expected_name} fixture, got {selected_ref}") + marker = run( + [ + str(python), + "-c", + "import importlib.util; print(importlib.util.find_spec('typing_extensions') is not None)", + ] + ) + if marker != "False": + raise AssertionError("The project's default-groups = [] setting was not preserved") + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + prepare_parser = subparsers.add_parser("prepare") + prepare_parser.add_argument("--root", type=Path, required=True) + prepare_parser.add_argument("--github-env", type=Path) + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("--root", type=Path, required=True) + verify_parser.add_argument("--scenario", choices=("selected", "normal"), required=True) + args = parser.parse_args() + + if args.command == "prepare": + prepare(args.root, args.github_env) + else: + verify(args.root, args.scenario) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/actions/sibling-refs/tests/test_resolve.py b/actions/sibling-refs/tests/test_resolve.py new file mode 100644 index 0000000..5920914 --- /dev/null +++ b/actions/sibling-refs/tests/test_resolve.py @@ -0,0 +1,351 @@ +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +MODULE_PATH = Path(__file__).parents[1] / "resolve.py" +SPEC = importlib.util.spec_from_file_location("sibling_refs_resolve", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +resolve = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resolve) + + +class TestParseReferences(unittest.TestCase): + def test_accepts_hash_and_bare_url_forms_in_body_order(self): + body = """ + sync: angr/cle#795; + [dependency](https://github.com/angr/archinfo/pull/375), + https://github.com/angr/pyvex/pull/576/files + """ + self.assertEqual( + list(resolve.parse_references(body)), + [("angr/cle", 795), ("angr/archinfo", 375)], + ) + + def test_ignores_non_numeric_and_unrelated_text(self): + self.assertEqual(list(resolve.parse_references("sync: angr/cle#head issue #42")), []) + + +class TestGitSources(unittest.TestCase): + def test_loads_only_simple_declared_github_git_sources(self): + with tempfile.TemporaryDirectory() as temporary_directory: + project = Path(temporary_directory) + (project / "pyproject.toml").write_text( + """ + [tool.uv.sources] + renamed-package = { git = "https://github.com/angr/cle.git", branch = "master" } + prefixed-package = { git = "git+https://github.com/angr/archinfo.git", branch = "master" } + ssh-package = { git = "git@github.com:angr/archinfo.git", branch = "master" } + conditional = [ + { git = "https://github.com/angr/pyvex.git", marker = "sys_platform == 'linux'" }, + ] + nested = { git = "https://github.com/angr/monorepo.git", subdirectory = "packages/nested" } + indexed = { index = "custom" } + elsewhere = { git = "https://example.com/owner/repository.git" } + """, + encoding="utf-8", + ) + + with mock.patch("sys.stderr"): + sources = resolve.load_git_sources(project) + self.assertEqual( + sources, + { + "angr/cle": [resolve.GitSource("renamed-package", "https://github.com/angr/cle.git")], + "angr/archinfo": [ + resolve.GitSource("prefixed-package", "git+https://github.com/angr/archinfo.git") + ], + }, + ) + + def test_rejects_unsafe_github_git_source_urls(self): + unsafe_urls = { + "username": "https://token@github.com/angr/cle.git", + "password": "https://user:secret@github.com/angr/cle.git", + "params": "https://github.com/angr/cle.git;token=secret", + "query": "https://github.com/angr/cle.git?token=secret", + "fragment": "https://github.com/angr/cle.git#token=secret", + "empty-query": "https://github.com/angr/cle.git?", + "empty-fragment": "https://github.com/angr/cle.git#", + "port": "https://github.com:443/angr/cle.git", + "extra-path": "https://github.com/angr/cle/tree/master", + "trailing-slash": "https://github.com/angr/cle.git/", + "space": "https://github.com/angr/cle repo.git", + } + with tempfile.TemporaryDirectory() as temporary_directory: + project = Path(temporary_directory) + source_lines = [ + f'{package} = {{ git = "{url}", branch = "master" }}' for package, url in unsafe_urls.items() + ] + (project / "pyproject.toml").write_text( + "[tool.uv.sources]\n" + "\n".join(source_lines) + "\n", + encoding="utf-8", + ) + + with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: + sources = resolve.load_git_sources(project) + + self.assertEqual(sources, {}) + self.assertNotIn("secret", stderr.getvalue()) + self.assertNotIn("token@", stderr.getvalue()) + + +class TestResolveOverrides(unittest.TestCase): + def setUp(self): + self.sources = { + "angr/cle": [resolve.GitSource("cle", "https://github.com/angr/cle.git")], + "angr/archinfo": [resolve.GitSource("arch-info", "https://github.com/angr/archinfo.git")], + } + + def test_resolves_multiple_open_siblings(self): + overrides = resolve.resolve_overrides( + "sync: angr/cle#795\nsync: https://github.com/angr/archinfo/pull/375", + self.sources, + lambda _repository, _number: "open", + ) + self.assertEqual( + overrides, + [ + resolve.Override("cle", "cle @ git+https://github.com/angr/cle.git@refs/pull/795/head"), + resolve.Override( + "arch-info", "arch-info @ git+https://github.com/angr/archinfo.git@refs/pull/375/head" + ), + ], + ) + + def test_uses_first_open_reference_for_each_repository(self): + states = {1: "closed", 2: "open", 3: "open"} + overrides = resolve.resolve_overrides( + "angr/cle#1 angr/cle#2 angr/cle#3", + self.sources, + lambda _repository, number: states[number], + ) + self.assertEqual( + overrides, + [resolve.Override("cle", "cle @ git+https://github.com/angr/cle.git@refs/pull/2/head")], + ) + + def test_deduplicates_package_names(self): + sources = { + "angr/cle": [resolve.GitSource("CLE", "https://github.com/angr/cle.git")], + "angr/other": [resolve.GitSource("cle", "https://github.com/angr/other.git")], + } + overrides = resolve.resolve_overrides( + "angr/cle#1 angr/other#2", + sources, + lambda _repository, _number: "open", + ) + self.assertEqual( + overrides, + [resolve.Override("CLE", "CLE @ git+https://github.com/angr/cle.git@refs/pull/1/head")], + ) + + def test_preserves_normal_sources_for_unavailable_closed_and_undeclared_refs(self): + states = {("angr/cle", 1): None, ("angr/cle", 2): "closed", ("angr/missing", 3): "open"} + overrides = resolve.resolve_overrides( + "angr/cle#1 angr/cle#2 angr/missing#3", + self.sources, + lambda repository, number: states[(repository, number)], + ) + self.assertEqual(overrides, []) + + def test_empty_body_does_not_query_github(self): + lookup = mock.Mock() + self.assertEqual(resolve.resolve_overrides("", self.sources, lookup), []) + lookup.assert_not_called() + + +class TestGithubApiConfiguration(unittest.TestCase): + def test_local_test_override_never_receives_the_github_token(self): + with tempfile.TemporaryDirectory() as temporary_directory: + api_response = Path(temporary_directory) / "repos" / "angr" / "cle" / "pulls" / "1" + api_response.parent.mkdir(parents=True) + api_response.write_text('{"state": "open"}', encoding="utf-8") + with ( + mock.patch.dict( + os.environ, + { + "SIBLING_REFS_TEST_API_URL": Path(temporary_directory).as_uri(), + "GH_TOKEN": "must-not-leave-the-runner", + }, + clear=True, + ), + mock.patch.object( + resolve.urllib.request, + "urlopen", + wraps=resolve.urllib.request.urlopen, + ) as urlopen, + ): + api_url, token = resolve.github_api_configuration() + self.assertIsNone(token) + self.assertEqual(resolve.pull_request_state(api_url, "angr/cle", 1, token), "open") + + request = urlopen.call_args.args[0] + self.assertIsNone(request.get_header("Authorization")) + + def test_rejects_unsafe_test_api_overrides(self): + unsafe_urls = ( + "https://example.invalid/api", + "not-a-url", + "file://example.invalid/api", + "file:///tmp/api?token=secret", + ) + for unsafe_url in unsafe_urls: + with self.subTest(unsafe_url=unsafe_url), mock.patch.dict( + os.environ, + {"SIBLING_REFS_TEST_API_URL": unsafe_url, "GH_TOKEN": "must-not-leave-the-runner"}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "must be a local file: URL"): + resolve.github_api_configuration() + + def test_production_api_retains_github_authentication(self): + with ( + mock.patch.dict( + os.environ, + {"GITHUB_API_URL": "https://api.github.example", "GH_TOKEN": "production-token"}, + clear=True, + ), + mock.patch.object( + resolve.urllib.request, + "urlopen", + return_value=io.BytesIO(b'{"state": "open"}'), + ) as urlopen, + ): + api_url, token = resolve.github_api_configuration() + self.assertEqual(resolve.pull_request_state(api_url, "angr/cle", 1, token), "open") + + request = urlopen.call_args.args[0] + self.assertEqual(request.full_url, "https://api.github.example/repos/angr/cle/pulls/1") + self.assertEqual(request.get_header("Authorization"), "Bearer production-token") + + +class TestOutput(unittest.TestCase): + def test_writes_the_paired_uv_settings(self): + overrides = [ + resolve.Override("cle", "cle @ git+https://github.com/angr/cle.git@refs/pull/795/head"), + resolve.Override( + "renamed-package", + "renamed-package @ git+https://github.com/angr/archinfo.git@refs/pull/375/head", + ), + ] + with tempfile.TemporaryDirectory() as temporary_directory: + config = Path(temporary_directory) / "refs.toml" + resolve.write_config(config, overrides) + self.assertEqual( + config.read_text(encoding="utf-8"), + """no-sources-package = [ + "cle", + "renamed-package", +] +upgrade-package = [ + "cle @ git+https://github.com/angr/cle.git@refs/pull/795/head", + "renamed-package @ git+https://github.com/angr/archinfo.git@refs/pull/375/head", +] +""", + ) + + def test_main_leaves_environment_unchanged_without_an_open_reference(self): + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_path = Path(temporary_directory) + project = temporary_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text("[tool.uv.sources]\n", encoding="utf-8") + config = temporary_path / "refs.toml" + github_env = temporary_path / "github-env" + github_env.touch() + argv = [ + "resolve.py", + "--project-directory", + str(project), + "--output", + str(config), + "--github-env", + str(github_env), + ] + with mock.patch.object(resolve.sys, "argv", argv), mock.patch.dict(os.environ, {"PR_BODY": ""}, clear=True): + self.assertEqual(resolve.main(), 0) + self.assertFalse(config.exists()) + self.assertEqual(github_env.read_text(encoding="utf-8"), "") + + def test_main_exports_config_for_an_open_declared_reference(self): + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_path = Path(temporary_directory) + project = temporary_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text( + '[tool.uv.sources]\ncle = { git = "https://github.com/angr/cle.git", branch = "master" }\n', + encoding="utf-8", + ) + config = temporary_path / "refs.toml" + github_env = temporary_path / "github-env" + github_env.touch() + argv = [ + "resolve.py", + "--project-directory", + str(project), + "--output", + str(config), + "--github-env", + str(github_env), + ] + with ( + mock.patch.object(resolve.sys, "argv", argv), + mock.patch.object(resolve, "pull_request_state", return_value="open"), + mock.patch.dict(os.environ, {"PR_BODY": "sync: angr/cle#795"}, clear=True), + ): + self.assertEqual(resolve.main(), 0) + self.assertEqual( + github_env.read_text(encoding="utf-8"), + f"UV_CONFIG_FILE={config}\n", + ) + + def test_main_rejects_an_unsafe_test_api_override_without_querying_it(self): + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_path = Path(temporary_directory) + project = temporary_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text( + '[tool.uv.sources]\ncle = { git = "https://github.com/angr/cle.git", branch = "master" }\n', + encoding="utf-8", + ) + config = temporary_path / "refs.toml" + github_env = temporary_path / "github-env" + github_env.touch() + argv = [ + "resolve.py", + "--project-directory", + str(project), + "--output", + str(config), + "--github-env", + str(github_env), + ] + with ( + mock.patch.object(resolve.sys, "argv", argv), + mock.patch.object(resolve.urllib.request, "urlopen") as urlopen, + mock.patch("sys.stderr", new_callable=io.StringIO) as stderr, + mock.patch.dict( + os.environ, + { + "PR_BODY": "sync: angr/cle#795", + "SIBLING_REFS_TEST_API_URL": "https://example.invalid/api", + "GH_TOKEN": "must-not-leave-the-runner", + }, + clear=True, + ), + ): + self.assertEqual(resolve.main(), 2) + + urlopen.assert_not_called() + self.assertIn("must be a local file: URL", stderr.getvalue()) + self.assertFalse(config.exists()) + self.assertEqual(github_env.read_text(encoding="utf-8"), "") + + +if __name__ == "__main__": + unittest.main()