diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 931eb86..f069c72 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -101,6 +101,7 @@ class SourceContext: github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN cache_lock: Lock = field(default_factory=Lock) + organizations_cache: dict[str, dict[str, Any]] = field(default_factory=dict) app_cache: dict[str, dict[str, Any]] = field(default_factory=dict) actions_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) runner_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) @@ -252,7 +253,13 @@ def organizations(ctx: SourceContext): org_name = org.org_name client = org.client try: - org_data = client.get(f"/orgs/{org_name}").json() + org_data = _cached_org_response( + ctx, + ctx.organizations_cache, + client, + org_name, + f"/orgs/{org_name}", + ) actions = _actions_permissions(ctx, client, org_name) self_hosted_runners = _runner_permissions(ctx, client, org_name) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 0999be1..c64d693 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -53,6 +53,7 @@ class SourceContext: github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN cache_lock: Lock = field(default_factory=Lock) + organizations_cache: dict[str, dict[str, Any]] = field(default_factory=dict) app_cache: dict[str, dict[str, Any]] = field(default_factory=dict) actions_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) runner_permissions_cache: dict[str, dict[str, Any]] = field(default_factory=dict) @@ -63,6 +64,31 @@ def org_names(self) -> list[str]: return [org.org_name for org in self.organizations or []] +def _canonicalize_org_names(ctx: SourceContext) -> None: + for org in ctx.organizations or []: + configured_name = org.org_name + try: + org_data = org.client.get(f"/orgs/{configured_name}").json() + except Exception as exc: + logger.warning( + "Unable to resolve canonical GitHub login for organization '%s': %s", + configured_name, + exc, + ) + continue + + canonical_name = org_data.get("login") + if not isinstance(canonical_name, str) or not canonical_name: + logger.warning( + "GitHub organization response for '%s' did not include a canonical login", + configured_name, + ) + continue + + org.org_name = canonical_name + ctx.organizations_cache[canonical_name] = org_data + + @configspec class GithubCredentials(CredentialsConfiguration): org_name: str | None = None @@ -228,6 +254,7 @@ def token_client(token: str) -> RESTClient: ) ) + _canonicalize_org_names(ctx) return organization_resources(ctx) else: @@ -255,4 +282,5 @@ def token_client(token: str) -> RESTClient: github_web_origin=github_web_origin, ) ) + _canonicalize_org_names(ctx) return organization_resources(ctx) diff --git a/tests/test_source_org_canonicalization.py b/tests/test_source_org_canonicalization.py new file mode 100644 index 0000000..743ab69 --- /dev/null +++ b/tests/test_source_org_canonicalization.py @@ -0,0 +1,138 @@ +import importlib +import inspect + +import pytest + +from openhound_github.resources.organization import organizations +from openhound_github.source import ( + GithubOrgAppCredentials, + GithubTokenCredentials, + OrgContext, + SourceContext, + _canonicalize_org_names, +) + + +class _FakeResponse: + def __init__(self, payload: dict): + self._payload = payload + + def json(self) -> dict: + return self._payload + + +class _FakeClient: + def __init__(self, payloads: dict[str, dict]): + self.payloads = payloads + self.get_calls: list[str] = [] + + def get(self, path: str) -> _FakeResponse: + self.get_calls.append(path) + return _FakeResponse(self.payloads[path]) + + +def test_canonicalize_org_names_rewrites_org_context_and_caches_response() -> None: + client = _FakeClient( + { + "/orgs/spectertst": { + "login": "SpecterTst", + "node_id": "O_kgDOCoV2OQ", + } + } + ) + ctx = SourceContext( + organizations=[OrgContext(client=client, org_name="spectertst")] + ) + + _canonicalize_org_names(ctx) + + assert ctx.organizations[0].org_name == "SpecterTst" + assert ctx.organizations_cache == { + "SpecterTst": { + "login": "SpecterTst", + "node_id": "O_kgDOCoV2OQ", + } + } + + +def test_organizations_reuses_preflight_org_response() -> None: + client = _FakeClient( + { + "/orgs/spectertst": { + "login": "SpecterTst", + "node_id": "O_kgDOCoV2OQ", + }, + "/orgs/SpecterTst/actions/permissions": {}, + "/orgs/SpecterTst/actions/permissions/self-hosted-runners": {}, + "/orgs/SpecterTst/actions/permissions/workflow": {}, + } + ) + ctx = SourceContext( + organizations=[OrgContext(client=client, org_name="spectertst")] + ) + + _canonicalize_org_names(ctx) + rows = list(inspect.unwrap(organizations._pipe.gen)(ctx)) + + assert rows[0]["login"] == "SpecterTst" + assert client.get_calls == [ + "/orgs/spectertst", + "/orgs/SpecterTst/actions/permissions", + "/orgs/SpecterTst/actions/permissions/self-hosted-runners", + "/orgs/SpecterTst/actions/permissions/workflow", + ] + + +@pytest.mark.parametrize( + "credentials", + ( + GithubTokenCredentials(token="token", org_name="spectertst"), + GithubOrgAppCredentials( + client_id="Iv1.client-id", + install_id="12345", + key_path="/tmp/github-app.pem", + org_name="spectertst", + ), + ), +) +def test_org_only_sources_canonicalize_before_resource_fanout( + monkeypatch: pytest.MonkeyPatch, + credentials, +) -> None: + source_module = importlib.import_module("openhound_github.source") + captured_ctx: dict[str, SourceContext] = {} + + class FakeRESTClient: + def __init__(self, **kwargs) -> None: + pass + + def get(self, path: str) -> _FakeResponse: + assert path == "/orgs/spectertst" + return _FakeResponse({"login": "SpecterTst", "node_id": "O_kgDOCoV2OQ"}) + + monkeypatch.setattr(source_module, "RESTClient", FakeRESTClient) + monkeypatch.setattr(source_module, "GithubInstallation", lambda **_: object()) + monkeypatch.setattr( + source_module, "GitHubAppInstallationAuth", lambda **_: object() + ) + + def fake_organization_resources(ctx: SourceContext): + captured_ctx["ctx"] = ctx + return () + + monkeypatch.setattr( + source_module, "organization_resources", fake_organization_resources + ) + + resources = source_module.source.__wrapped__( + credentials=credentials, + host="https://api.github.com", + emit_legacy_scim_correlations=False, + ) + + assert resources == () + assert captured_ctx["ctx"].organizations[0].org_name == "SpecterTst" + assert ( + captured_ctx["ctx"].organizations_cache["SpecterTst"]["node_id"] + == "O_kgDOCoV2OQ" + )