Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/openhound_github/resources/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions src/openhound_github/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -228,6 +254,7 @@ def token_client(token: str) -> RESTClient:
)
)

_canonicalize_org_names(ctx)
return organization_resources(ctx)

else:
Expand Down Expand Up @@ -255,4 +282,5 @@ def token_client(token: str) -> RESTClient:
github_web_origin=github_web_origin,
)
)
_canonicalize_org_names(ctx)
return organization_resources(ctx)
138 changes: 138 additions & 0 deletions tests/test_source_org_canonicalization.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading