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
1 change: 0 additions & 1 deletion src/openhound_github/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
createdAt
updatedAt
billingEmail
securityContactEmail
viewerIsAdmin
organizations(first: 100, after: $after) {
nodes {
Expand Down
14 changes: 8 additions & 6 deletions src/openhound_github/models/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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",
),
)
Expand Down
8 changes: 8 additions & 0 deletions src/openhound_github/models/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/openhound_github/resources/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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}",
Expand Down
8 changes: 8 additions & 0 deletions src/openhound_github/resources/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
79 changes: 79 additions & 0 deletions src/openhound_github/source.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Union
Expand Down Expand Up @@ -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<version>\d+\.\d+(?:\.\d+)?)")


def _normalize_endpoint_url(url: str, setting_name: str) -> str:
normalized_url = url.strip().rstrip("/")
parsed = urlparse(normalized_url)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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)
13 changes: 12 additions & 1 deletion tests/test_enterprise_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
70 changes: 70 additions & 0 deletions tests/test_source_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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/",
Expand Down
Loading
Loading