diff --git a/src/openhound_github/graphql.py b/src/openhound_github/graphql.py index cbd1d02..16e07d3 100644 --- a/src/openhound_github/graphql.py +++ b/src/openhound_github/graphql.py @@ -36,7 +36,6 @@ createdAt updatedAt billingEmail - securityContactEmail viewerIsAdmin organizations(first: 100, after: $after) { nodes { diff --git a/src/openhound_github/models/enterprise.py b/src/openhound_github/models/enterprise.py index dc905ff..7dc7958 100644 --- a/src/openhound_github/models/enterprise.py +++ b/src/openhound_github/models/enterprise.py @@ -25,8 +25,9 @@ class GHEnterpriseProperties(GHNodeProperties): created_at: When the enterprise was created. updated_at: When the enterprise was last updated. billing_email: The enterprise billing email. - security_contact_email: The enterprise security contact email. viewer_is_admin: Whether the authenticated viewer is an enterprise admin. + github_deployment_type: The GitHub deployment type, such as `ghec` or `ghes`. + ghes_version: The GitHub Enterprise Server version, when applicable. environment_name: The enterprise environment name. query_organizations: Query for contained organizations. """ @@ -41,8 +42,9 @@ class GHEnterpriseProperties(GHNodeProperties): created_at: str | None = None updated_at: str | None = None billing_email: str | None = None - security_contact_email: str | None = None viewer_is_admin: bool | None = None + github_deployment_type: str | None = None + ghes_version: str | None = None environment_name: str | None = None query_organizations: str | None = None @@ -70,10 +72,9 @@ class Enterprise(BaseAsset): created_at: str | None = Field(alias="createdAt", default=None) updated_at: str | None = Field(alias="updatedAt", default=None) billing_email: str | None = Field(alias="billingEmail", default=None) - security_contact_email: str | None = Field( - alias="securityContactEmail", default=None - ) viewer_is_admin: bool | None = Field(alias="viewerIsAdmin", default=None) + github_deployment_type: str | None = None + ghes_version: str | None = None organizations: dict | None = Field(default_factory=dict) @property @@ -99,8 +100,9 @@ def as_node(self) -> GHNode: created_at=self.created_at, updated_at=self.updated_at, billing_email=self.billing_email, - security_contact_email=self.security_contact_email, viewer_is_admin=self.viewer_is_admin, + github_deployment_type=self.github_deployment_type, + ghes_version=self.ghes_version, query_organizations=f"MATCH p=(:GH_Enterprise {{node_id:'{self.node_id}'}})-[:GH_Contains]->(:GH_Organization) RETURN p", ), ) diff --git a/src/openhound_github/models/org.py b/src/openhound_github/models/org.py index 91bd3be..2f5a224 100644 --- a/src/openhound_github/models/org.py +++ b/src/openhound_github/models/org.py @@ -74,6 +74,8 @@ class GHOrganizationProperties(GHNodeProperties): self_hosted_runners_enabled_repositories: Which repositories may use self-hosted runners: `all`, `selected`, or `none`. default_workflow_permissions: The default workflow permissions property. can_approve_pull_request_reviews: The can approve pull request reviews property. + github_deployment_type: The GitHub deployment type, such as `ghec` or `ghes`. + ghes_version: The GitHub Enterprise Server version, when applicable. query_organization_roles: Query for organization roles. query_users: Query for users. query_teams: Query for teams. @@ -145,6 +147,8 @@ class GHOrganizationProperties(GHNodeProperties): self_hosted_runners_enabled_repositories: str | None = None default_workflow_permissions: str | None = None can_approve_pull_request_reviews: bool | None = None + github_deployment_type: str | None = None + ghes_version: str | None = None query_organization_roles: str | None = None query_users: str | None = None query_teams: str | None = None @@ -229,6 +233,8 @@ class Organization(BaseAsset): self_hosted_runners_enabled_repositories: str | None = None default_workflow_permissions: str | None = None can_approve_pull_request_reviews: bool | None = None + github_deployment_type: str | None = None + ghes_version: str | None = None @property def as_node(self) -> GHNode: @@ -300,6 +306,8 @@ def as_node(self) -> GHNode: self_hosted_runners_enabled_repositories=self.self_hosted_runners_enabled_repositories, default_workflow_permissions=self.default_workflow_permissions, can_approve_pull_request_reviews=self.can_approve_pull_request_reviews, + github_deployment_type=self.github_deployment_type, + ghes_version=self.ghes_version, query_organization_roles=f"MATCH (:GH_Organization {{node_id:'{oid}'}})-[:GH_Contains]->(n:GH_OrgRole) RETURN n", query_users=f"MATCH (n:GH_User {{environmentid:'{oid}'}}) RETURN n", query_teams=f"MATCH (n:GH_Team {{environmentid:'{oid}'}}) RETURN n", diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index a9c53c7..8951967 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -67,6 +67,9 @@ class SourceContext: emit_legacy_scim_correlations: bool = False github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN + deployment_type: str = "unknown" + ghes_version: str | None = None + enterprise_version_header: str | None = None organizations: list[Any] = field(default_factory=list) @@ -133,7 +136,11 @@ def enterprise(ctx: SourceContext): response = client.post(graphql_path, json=data).json() page_enterprise = (response.get("data") or {}).get("enterprise") if page_enterprise: - yield page_enterprise + yield { + **page_enterprise, + "github_deployment_type": ctx.deployment_type, + "ghes_version": ctx.ghes_version, + } except Exception as e: logger.error( f"Error in resource 'enterprise' processing enterprise '{ctx.enterprise_name}': {e}", diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index f0b5486..3f42c3c 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -99,6 +99,9 @@ class OrgContext: enterprise_name: str | None = None github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN + deployment_type: str = "unknown" + ghes_version: str | None = None + enterprise_version_header: str | None = None @dataclass @@ -109,6 +112,9 @@ class SourceContext: enterprise_name: str | None = None github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN + deployment_type: str = "unknown" + ghes_version: str | None = None + enterprise_version_header: str | None = None 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) @@ -478,6 +484,8 @@ def organizations(ctx: SourceContext): org_data["can_approve_pull_request_reviews"] = workflow_perms.get( "can_approve_pull_request_reviews" ) + org_data["github_deployment_type"] = ctx.deployment_type + org_data["ghes_version"] = ctx.ghes_version yield org_data except Exception as e: diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 7264f74..e6dc788 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -1,4 +1,5 @@ import logging +import re from dataclasses import dataclass, field from threading import Lock from typing import Any, Union @@ -44,6 +45,16 @@ class GithubEndpoints: graphql_url: str +@dataclass(frozen=True) +class GithubDeploymentMetadata: + deployment_type: str = "unknown" + ghes_version: str | None = None + enterprise_version_header: str | None = None + + +_GHES_VERSION_PATTERN = re.compile(r"(?:enterprise-server@)?(?P\d+\.\d+(?:\.\d+)?)") + + def _normalize_endpoint_url(url: str, setting_name: str) -> str: normalized_url = url.strip().rstrip("/") parsed = urlparse(normalized_url) @@ -140,6 +151,9 @@ class OrgContext: enterprise_name: str | None = None github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN + deployment_type: str = "unknown" + ghes_version: str | None = None + enterprise_version_header: str | None = None @dataclass @@ -153,6 +167,9 @@ class SourceContext: emit_legacy_scim_correlations: bool = False github_deployment_id: str = DEFAULT_GITHUB_DEPLOYMENT_ID github_web_origin: str = DEFAULT_GITHUB_WEB_ORIGIN + deployment_type: str = "unknown" + ghes_version: str | None = None + enterprise_version_header: str | None = None 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) @@ -166,6 +183,60 @@ def org_names(self) -> list[str]: return [org.org_name for org in self.organizations or []] +def _ghes_version_from_header(header: str | None) -> str | None: + if not header: + return None + match = _GHES_VERSION_PATTERN.search(header) + return match.group("version") if match else None + + +def _detect_github_deployment(client: RESTClient) -> GithubDeploymentMetadata: + try: + response = client.get("/meta") + payload = response.json() + except Exception as exc: + logger.warning("Unable to detect GitHub deployment metadata from /meta: %s", exc) + return GithubDeploymentMetadata() + + if not isinstance(payload, dict): + payload = {} + + headers = getattr(response, "headers", {}) + enterprise_version_header = None + if hasattr(headers, "get"): + header_value = headers.get("X-GitHub-Enterprise-Version") + if isinstance(header_value, str) and header_value: + enterprise_version_header = header_value + + installed_version = payload.get("installed_version") + if not isinstance(installed_version, str) or not installed_version: + installed_version = _ghes_version_from_header(enterprise_version_header) + + if installed_version or enterprise_version_header: + return GithubDeploymentMetadata( + deployment_type="ghes", + ghes_version=installed_version, + enterprise_version_header=enterprise_version_header, + ) + + return GithubDeploymentMetadata(deployment_type="ghec") + + +def _apply_github_deployment_metadata( + ctx: SourceContext, + client: RESTClient, +) -> GithubDeploymentMetadata: + metadata = _detect_github_deployment(client) + ctx.deployment_type = metadata.deployment_type + ctx.ghes_version = metadata.ghes_version + ctx.enterprise_version_header = metadata.enterprise_version_header + for org in ctx.organizations or []: + org.deployment_type = metadata.deployment_type + org.ghes_version = metadata.ghes_version + org.enterprise_version_header = metadata.enterprise_version_header + return metadata + + def _canonicalize_org_names(ctx: SourceContext) -> None: for org in ctx.organizations or []: configured_name = org.org_name @@ -354,6 +425,11 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: ) ) + deployment_client = ctx.client + if deployment_client is None and ctx.organizations: + deployment_client = ctx.organizations[0].client + if deployment_client is not None: + _apply_github_deployment_metadata(ctx, deployment_client) return (*enterprise_resources(ctx), *organization_resources(ctx)) elif credentials.auth == "org_app": @@ -388,6 +464,7 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: ) ) + _apply_github_deployment_metadata(ctx, org_client) _canonicalize_org_names(ctx) return organization_resources(ctx) @@ -404,6 +481,7 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: github_deployment_id=github_deployment_id, github_web_origin=github_web_origin, ) + _apply_github_deployment_metadata(ctx, token_api_client) return enterprise_resources(ctx) token_api_client, token_graphql_client = token_clients(credentials.token) @@ -420,5 +498,6 @@ def token_clients(token: str) -> tuple[RESTClient, RESTClient]: github_web_origin=github_web_origin, ) ) + _apply_github_deployment_metadata(ctx, token_api_client) _canonicalize_org_names(ctx) return organization_resources(ctx) diff --git a/tests/test_enterprise_resources.py b/tests/test_enterprise_resources.py index e195c78..3021165 100644 --- a/tests/test_enterprise_resources.py +++ b/tests/test_enterprise_resources.py @@ -92,12 +92,23 @@ def test_enterprise_resource_yields_single_record() -> None: } } ) - ctx = SourceContext(client=client, enterprise_name="acme") + ctx = SourceContext( + client=client, + enterprise_name="acme", + deployment_type="ghes", + ghes_version="3.22.1", + ) rows = list(enterprise(ctx)) assert len(rows) == 1 assert rows[0].id == "E_1" + assert rows[0].github_deployment_type == "ghes" + assert rows[0].ghes_version == "3.22.1" + + rows[0]._lookup = SimpleNamespace(enterprise_id=lambda: "E_1") + assert rows[0].as_node.properties.github_deployment_type == "ghes" + assert rows[0].as_node.properties.ghes_version == "3.22.1" assert len(client.post_calls) == 1 diff --git a/tests/test_source_endpoints.py b/tests/test_source_endpoints.py index 58dea2e..386a406 100644 --- a/tests/test_source_endpoints.py +++ b/tests/test_source_endpoints.py @@ -15,13 +15,19 @@ secret_scanning_alerts, users, ) +from openhound_github.graphql import ENTERPRISE_QUERY from openhound_github.source import ( DEFAULT_GITHUB_GRAPHQL_URL, DEFAULT_GITHUB_REST_API_URL, + GithubDeploymentMetadata, GithubEndpoints, GithubEnterpriseAppCredentials, GithubOrgAppCredentials, GithubTokenCredentials, + OrgContext, + SourceContext, + _apply_github_deployment_metadata, + _detect_github_deployment, resolve_github_endpoints, ) @@ -33,6 +39,70 @@ def test_resolve_github_endpoints_uses_dotcom_defaults() -> None: ) +@pytest.mark.parametrize( + ("payload", "headers", "expected"), + ( + ( + {"installed_version": "3.22.1"}, + {}, + GithubDeploymentMetadata(deployment_type="ghes", ghes_version="3.22.1"), + ), + ( + {}, + {"X-GitHub-Enterprise-Version": "enterprise-server@3.21.0"}, + GithubDeploymentMetadata( + deployment_type="ghes", + ghes_version="3.21.0", + enterprise_version_header="enterprise-server@3.21.0", + ), + ), + ( + {}, + {}, + GithubDeploymentMetadata(deployment_type="ghec"), + ), + ), +) +def test_detect_github_deployment_from_meta( + payload: dict[str, str], + headers: dict[str, str], + expected: GithubDeploymentMetadata, +) -> None: + response = SimpleNamespace(headers=headers, json=lambda: payload) + client = MagicMock() + client.get.return_value = response + + assert _detect_github_deployment(client) == expected + client.get.assert_called_once_with("/meta") + + +def test_apply_github_deployment_metadata_updates_source_and_org_contexts() -> None: + client = MagicMock() + client.get.return_value = SimpleNamespace( + headers={"X-GitHub-Enterprise-Version": "3.20.4"}, + json=lambda: {"installed_version": "3.20.4"}, + ) + ctx = SourceContext(organizations=[OrgContext(client=client, org_name="acme")]) + + metadata = _apply_github_deployment_metadata(ctx, client) + + assert metadata == GithubDeploymentMetadata( + deployment_type="ghes", + ghes_version="3.20.4", + enterprise_version_header="3.20.4", + ) + assert ctx.deployment_type == "ghes" + assert ctx.ghes_version == "3.20.4" + assert ctx.enterprise_version_header == "3.20.4" + assert ctx.organizations[0].deployment_type == "ghes" + assert ctx.organizations[0].ghes_version == "3.20.4" + assert ctx.organizations[0].enterprise_version_header == "3.20.4" + + +def test_enterprise_query_omits_security_contact_email() -> None: + assert "securityContactEmail" not in ENTERPRISE_QUERY + + def test_resolve_github_endpoints_uses_explicit_endpoint_pair() -> None: assert resolve_github_endpoints( rest_api_url="https://ghe.example/api/v3/", diff --git a/tests/test_source_org_canonicalization.py b/tests/test_source_org_canonicalization.py index 743ab69..7393ca5 100644 --- a/tests/test_source_org_canonicalization.py +++ b/tests/test_source_org_canonicalization.py @@ -3,6 +3,7 @@ import pytest +from openhound_github.models import Organization from openhound_github.resources.organization import organizations from openhound_github.source import ( GithubOrgAppCredentials, @@ -68,13 +69,20 @@ def test_organizations_reuses_preflight_org_response() -> None: } ) ctx = SourceContext( - organizations=[OrgContext(client=client, org_name="spectertst")] + organizations=[OrgContext(client=client, org_name="spectertst")], + deployment_type="ghes", + ghes_version="3.22.1", ) _canonicalize_org_names(ctx) rows = list(inspect.unwrap(organizations._pipe.gen)(ctx)) assert rows[0]["login"] == "SpecterTst" + assert rows[0]["github_deployment_type"] == "ghes" + assert rows[0]["ghes_version"] == "3.22.1" + node = Organization(**rows[0]).as_node + assert node.properties.github_deployment_type == "ghes" + assert node.properties.ghes_version == "3.22.1" assert client.get_calls == [ "/orgs/spectertst", "/orgs/SpecterTst/actions/permissions",