diff --git a/descriptions/edges/GH_AssignedTo.md b/descriptions/edges/GH_AssignedTo.md new file mode 100644 index 0000000..2e00c80 --- /dev/null +++ b/descriptions/edges/GH_AssignedTo.md @@ -0,0 +1,5 @@ +## General Information + +The non-traversable GH_AssignedTo edge represents an enterprise-scoped object being assigned to an organization. It is currently used for enterprise teams and enterprise runner groups. For runner groups, this edge indicates that an organization is allowed to inherit and use the enterprise-owned runner group. + +This edge is not traversable because assignment alone does not directly grant a principal a privilege path. diff --git a/descriptions/edges/GH_CanUseRunner.md b/descriptions/edges/GH_CanUseRunner.md new file mode 100644 index 0000000..24bee42 --- /dev/null +++ b/descriptions/edges/GH_CanUseRunner.md @@ -0,0 +1,5 @@ +## General Information + +The non-traversable GH_CanUseRunner edge represents that a repository can dispatch GitHub Actions jobs to a self-hosted runner. Repository-scoped runners receive this edge directly from their containing repository. Organization and enterprise runners receive composed GH_CanUseRunner edges derived from runner group containment and repository access policy. + +For native organization runner groups, the composition path is `GH_Repository <- GH_GrantsAccessTo - GH_OrgRunnerGroup - GH_Contains -> GH_OrgRunner`. For inherited groups, the path continues through `GH_InheritedFrom` to the enterprise runner group and its contained GH_EnterpriseRunner nodes. diff --git a/descriptions/edges/GH_Contains.md b/descriptions/edges/GH_Contains.md index 9522b0d..681a1eb 100644 --- a/descriptions/edges/GH_Contains.md +++ b/descriptions/edges/GH_Contains.md @@ -1,3 +1,3 @@ ## General Information -The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The organization serves as the top-level container for users, teams, repositories, roles, secrets, app installations, and personal access tokens. Repositories contain branches, workflows, branch protection rules, environments, and repo-level secrets and variables. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation. +The non-traversable GH_Contains edge represents structural containment within the GitHub resource hierarchy. The enterprise contains enterprise teams, roles, managed users, runner groups, and enterprise runners through their groups. The organization serves as a top-level container for users, teams, repositories, roles, secrets, app installations, personal access tokens, and organization runner groups. Native organization runner groups contain organization runners. Repositories contain branches, workflows, branch protection rules, environments, repo-level secrets and variables, and repository-scoped runners. Environments contain environment branch policies, environment-scoped secrets, and environment-scoped variables. This edge is created by the collector to establish the resource hierarchy and is not traversable because containment alone does not imply privilege escalation. diff --git a/descriptions/edges/GH_GrantsAccessTo.md b/descriptions/edges/GH_GrantsAccessTo.md new file mode 100644 index 0000000..3182aa7 --- /dev/null +++ b/descriptions/edges/GH_GrantsAccessTo.md @@ -0,0 +1,5 @@ +## General Information + +The non-traversable GH_GrantsAccessTo edge represents that a GH_OrgRunnerGroup allows a repository to use the runners exposed by that group. For native groups, those runners are GH_OrgRunner nodes contained by the group. For inherited groups, those runners are GH_EnterpriseRunner nodes reached through GH_InheritedFrom. + +This edge is used as part of the composition path for GH_CanUseRunner and is not traversable on its own because repository eligibility alone is not a privilege escalation path. diff --git a/descriptions/edges/GH_InheritedFrom.md b/descriptions/edges/GH_InheritedFrom.md new file mode 100644 index 0000000..dc70b3f --- /dev/null +++ b/descriptions/edges/GH_InheritedFrom.md @@ -0,0 +1,5 @@ +## General Information + +The non-traversable GH_InheritedFrom edge links an inherited GH_OrgRunnerGroup to the GH_EnterpriseRunnerGroup that owns the underlying runner set. This preserves the organization-local view of a runner group while still identifying the enterprise source that provides the runners. + +This edge is not traversable because inheritance describes resource provenance rather than a direct privilege path. diff --git a/descriptions/nodes/GH_EnterpriseRunner.md b/descriptions/nodes/GH_EnterpriseRunner.md new file mode 100644 index 0000000..e42e6c7 --- /dev/null +++ b/descriptions/nodes/GH_EnterpriseRunner.md @@ -0,0 +1,5 @@ +## Description + +Represents a self-hosted runner owned at the GitHub Enterprise level. Enterprise runners are contained by GH_EnterpriseRunnerGroup nodes and become usable by repositories when an inherited organization runner group grants access to those repositories. + +The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/descriptions/nodes/GH_EnterpriseRunnerGroup.md b/descriptions/nodes/GH_EnterpriseRunnerGroup.md new file mode 100644 index 0000000..b002d5d --- /dev/null +++ b/descriptions/nodes/GH_EnterpriseRunnerGroup.md @@ -0,0 +1,5 @@ +## Description + +Represents a self-hosted runner group owned by a GitHub Enterprise account. Enterprise runner groups control which organizations may use a shared set of enterprise runners. Groups with `visibility=all` are assigned to every organization in the enterprise, while groups with `visibility=selected` are assigned only to explicitly selected organizations. + +Enterprise runner groups contain GH_EnterpriseRunner nodes and may be projected into organizations as inherited GH_OrgRunnerGroup nodes. The GH_InheritedFrom edge links the organization view back to the enterprise-owned group. diff --git a/descriptions/nodes/GH_OrgRunner.md b/descriptions/nodes/GH_OrgRunner.md new file mode 100644 index 0000000..fc4a03c --- /dev/null +++ b/descriptions/nodes/GH_OrgRunner.md @@ -0,0 +1,5 @@ +## Description + +Represents a self-hosted runner owned by a GitHub organization. Organization runners are contained by native GH_OrgRunnerGroup nodes and become usable by repositories through the runner group's repository access policy. + +The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/descriptions/nodes/GH_OrgRunnerGroup.md b/descriptions/nodes/GH_OrgRunnerGroup.md new file mode 100644 index 0000000..07ea2eb --- /dev/null +++ b/descriptions/nodes/GH_OrgRunnerGroup.md @@ -0,0 +1,5 @@ +## Description + +Represents a self-hosted runner group visible within a GitHub organization. Organization runner groups may either be native to the organization or inherited from an enterprise runner group. + +Native organization runner groups contain GH_OrgRunner nodes directly. Inherited organization runner groups do not directly contain organization runners; instead, they link to the source GH_EnterpriseRunnerGroup through GH_InheritedFrom and gain access to the enterprise runners contained there. GH_GrantsAccessTo edges describe which repositories may use the runners exposed by the group. diff --git a/descriptions/nodes/GH_RepoRunner.md b/descriptions/nodes/GH_RepoRunner.md new file mode 100644 index 0000000..f135f63 --- /dev/null +++ b/descriptions/nodes/GH_RepoRunner.md @@ -0,0 +1,5 @@ +## Description + +Represents a self-hosted runner registered directly to a single GitHub repository. Repository runners are contained by that repository and may only be used by workflows in that repository. + +The node captures runner metadata such as operating system, status, busy state, labels, and whether the runner is ephemeral when GitHub returns that property. diff --git a/extension/schema.json b/extension/schema.json index b2e4417..ad41fbd 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -2,7 +2,7 @@ "schema": { "name": "SOGitHub", "display_name": "GitHub Extension (by SpecterOps)", - "version": "v1.3.0", + "version": "v1.3.1", "namespace": "GH" }, "node_kinds": [ @@ -12,7 +12,7 @@ "description": "A GitHub Enterprise account that contains organizations, enterprise teams, roles, and managed users", "is_display_kind": true, "icon": "globe", - "color": "#6EA8FE" + "color": "#7C6FF2" }, { "name": "GH_EnterpriseTeam", @@ -180,7 +180,7 @@ "description": "An organization-level GitHub Actions variable that can be scoped to all, private, or selected repositories. Unlike secrets, variable values are readable.", "is_display_kind": true, "icon": "lock-open", - "color": "#E8B84D" + "color": "#78D98B" }, { "name": "GH_RepoVariable", @@ -188,15 +188,55 @@ "description": "A repository-level GitHub Actions variable accessible only to workflows in that repository. Unlike secrets, variable values are readable.", "is_display_kind": true, "icon": "lock-open", - "color": "#E89B5C" + "color": "#66C7E8" + }, + { + "name": "GH_Secret", + "display_name": "GitHub Secret", + "description": "Generic label applied to GitHub secret nodes across organization, repository, and environment scope", + "is_display_kind": false + }, + { + "name": "GH_Variable", + "display_name": "GitHub Variable", + "description": "Generic label applied to GitHub variable nodes across organization, repository, and environment scope", + "is_display_kind": false }, { "name": "GH_RunnerGroup", "display_name": "GitHub Runner Group", - "description": "A GitHub self-hosted runner group that controls runner access and visibility", + "description": "Generic label applied to GitHub self-hosted runner group nodes across enterprise and organization scope", + "is_display_kind": false + }, + { + "name": "GH_Runner", + "display_name": "GitHub Runner", + "description": "Generic label applied to GitHub self-hosted runner nodes across enterprise, organization, and repository scope", + "is_display_kind": false + }, + { + "name": "GH_EnterpriseRunnerGroup", + "display_name": "GitHub Enterprise Runner Group", + "description": "An enterprise-scoped GitHub self-hosted runner group that controls runner access and visibility", "is_display_kind": true, "icon": "server", - "color": "#94A3B8" + "color": "#9A8CF5" + }, + { + "name": "GH_OrgRunnerGroup", + "display_name": "GitHub Org Runner Group", + "description": "An organization-scoped GitHub self-hosted runner group that controls runner access and visibility", + "is_display_kind": true, + "icon": "server", + "color": "#7BE89A" + }, + { + "name": "GH_EnterpriseRunner", + "display_name": "GitHub Enterprise Runner", + "description": "An enterprise-scoped GitHub self-hosted runner available to organizations through enterprise runner groups", + "is_display_kind": true, + "icon": "microchip", + "color": "#5F54D9" }, { "name": "GH_OrgRunner", @@ -220,7 +260,7 @@ "description": "An environment-level GitHub Actions variable scoped to a specific deployment environment. Unlike secrets, variable values are readable.", "is_display_kind": true, "icon": "lock-open", - "color": "#D4A84B" + "color": "#A7D77B" }, { "name": "GH_SecretScanningAlert", @@ -402,7 +442,17 @@ }, { "name": "GH_AssignedTo", - "description": "Enterprise team is assigned to an organization", + "description": "Enterprise-scoped team or runner group is assigned to an organization", + "is_traversable": false + }, + { + "name": "GH_InheritedFrom", + "description": "Organization runner group is inherited from an enterprise runner group", + "is_traversable": false + }, + { + "name": "GH_GrantsAccessTo", + "description": "Organization runner group grants a repository access to its available runners", "is_traversable": false }, { @@ -917,7 +967,7 @@ }, { "name": "GH_CanUseRunner", - "description": "Repository can dispatch jobs to this runner", + "description": "Repository can dispatch jobs to this self-hosted runner", "is_traversable": false }, { @@ -1008,6 +1058,13 @@ "principal_kinds": [ "GH_User" ] + }, + { + "environment_kind": "GH_Enterprise", + "source_kind": "GitHub", + "principal_kinds": [ + "GH_User" + ] } ], "relationship_findings": [] diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index 17baeda..c9e0e9b 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -1,6 +1,8 @@ # Generic CONTAINS = "GH_Contains" ASSIGNED_TO = "GH_AssignedTo" +INHERITED_FROM = "GH_InheritedFrom" +GRANTS_ACCESS_TO = "GH_GrantsAccessTo" # Administrative edges ADMIN_TO = "GH_AdminTo" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index e1e4443..b8efdc1 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -39,7 +39,10 @@ # Runner nodes RUNNER = "GH_Runner" RUNNER_GROUP = "GH_RunnerGroup" +ENTERPRISE_RUNNER = "GH_EnterpriseRunner" +ENTERPRISE_RUNNER_GROUP = "GH_EnterpriseRunnerGroup" ORG_RUNNER = "GH_OrgRunner" +ORG_RUNNER_GROUP = "GH_OrgRunnerGroup" # Security nodes SECRET_SCANNING_ALERT = "GH_SecretScanningAlert" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 63c2b3e..dfbe0a6 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -4,6 +4,8 @@ from duckdb import DuckDBPyConnection from openhound.core.lookup import LookupManager, logger +from openhound_github.runner_ids import runner_group_node_id, runner_node_id + class GithubLookup(LookupManager): def __init__(self, client: DuckDBPyConnection, schema: str = "github"): @@ -51,6 +53,84 @@ def enterprise_id(self) -> str | None: res = self._find_single_object(f"""SELECT id FROM {self.schema}.enterprise""") return res + @lru_cache + def enterprise_organization_node_ids(self, enterprise_node_id: str): + return self._find_all_objects( + f"""SELECT id FROM {self.schema}.enterprise_organizations WHERE enterprise_node_id = ?""", + [enterprise_node_id], + ) + + @lru_cache + def _enterprise_runner_group_identity_for_inherited_org_group( + self, org_node_id: str, group_name: str + ) -> tuple[str, int] | None: + rows = self._find_all_objects( + f""" + WITH candidate_groups AS ( + SELECT erg.enterprise_node_id, erg.id + FROM {self.schema}.enterprise_runner_groups erg + JOIN {self.schema}.enterprise_organizations eo + ON eo.enterprise_node_id = erg.enterprise_node_id + WHERE eo.id = ? + AND erg.visibility = 'all' + AND erg.name = ? + + UNION + + SELECT erg.enterprise_node_id, erg.id + FROM {self.schema}.enterprise_runner_groups erg + JOIN {self.schema}.enterprise_runner_group_organizations ergo + ON ergo.enterprise_node_id = erg.enterprise_node_id + AND ergo.runner_group_id = erg.id + WHERE ergo.node_id = ? + AND erg.name = ? + ) + SELECT enterprise_node_id, id + FROM candidate_groups + """, + [org_node_id, group_name, org_node_id, group_name], + ) + if not rows or len(rows) != 1: + return None + + enterprise_node_id, runner_group_id = rows[0] + return str(enterprise_node_id), int(runner_group_id) + + @lru_cache + def enterprise_runner_group_node_id_for_inherited_org_group( + self, org_node_id: str, group_name: str + ) -> str | None: + identity = self._enterprise_runner_group_identity_for_inherited_org_group( + org_node_id, group_name + ) + if not identity: + return None + + enterprise_node_id, runner_group_id = identity + return runner_group_node_id(enterprise_node_id, runner_group_id) + + @lru_cache + def enterprise_runner_node_ids_for_inherited_org_group( + self, org_node_id: str, group_name: str + ): + identity = self._enterprise_runner_group_identity_for_inherited_org_group( + org_node_id, group_name + ) + if not identity: + return [] + + enterprise_node_id, runner_group_id = identity + rows = self._find_all_objects( + f""" + SELECT runner_id + FROM {self.schema}.enterprise_runner_group_memberships + WHERE enterprise_node_id = ? + AND runner_group_id = ? + """, + [enterprise_node_id, runner_group_id], + ) + return [(runner_node_id(enterprise_node_id, int(runner_id)),) for (runner_id,) in rows] + @lru_cache def enterprise_idp_for_scope( self, enterprise_node_id: str diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index 0a1df0b..9cc182d 100644 --- a/src/openhound_github/main.py +++ b/src/openhound_github/main.py @@ -62,6 +62,10 @@ def preproc(ctx: PreProcContext): "saml_provider": "saml_provider", "applications": "applications", "enterprise": "enterprise", + "enterprise_organizations": "enterprise_organizations", + "enterprise_runner_groups": "enterprise_runner_groups", + "enterprise_runner_group_organizations": "enterprise_runner_group_organizations", + "enterprise_runner_group_memberships": "enterprise_runner_group_memberships", "org_roles": "org_roles", "projected_enterprise_teams": "projected_enterprise_teams", "environments": "environments", diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 2002c90..0c52692 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -38,7 +38,17 @@ from .repository_role import BaseRepoRole, RepoRole from .repository_secret import RepoSecret from .repository_variable import RepoVariable -from .runner import OrgRunner, OrgRunnerGroupMembership, RepoRunner, RunnerGroup +from .runner import ( + EnterpriseRunner, + EnterpriseRunnerGroup, + EnterpriseRunnerGroupMembership, + EnterpriseRunnerGroupOrganization, + OrgRunner, + OrgRunnerGroup, + OrgRunnerGroupAccess, + OrgRunnerGroupMembership, + RepoRunner, +) from .saml_assertion_consumer_service import SamlAssertionConsumerService from .saml_provider import SamlProvider from .saml_service_provider import SamlServiceProvider @@ -79,8 +89,13 @@ "SelectedOrgVariable", "RepoSecret", "RepoVariable", - "RunnerGroup", + "EnterpriseRunner", + "EnterpriseRunnerGroup", + "EnterpriseRunnerGroupMembership", + "EnterpriseRunnerGroupOrganization", "OrgRunner", + "OrgRunnerGroup", + "OrgRunnerGroupAccess", "OrgRunnerGroupMembership", "RepoRunner", "SecretScanningAlert", diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index c6e21bd..d37b294 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -7,10 +7,11 @@ from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties from pydantic import Field -from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.graph import GHEdgeProperties, GHNode, GHNodeProperties from openhound_github.kinds import edges as ek from openhound_github.kinds import nodes as nk from openhound_github.main import app +from openhound_github.runner_ids import runner_group_node_id, runner_node_id @dataclass @@ -18,6 +19,7 @@ class GHRunnerGroupProperties(GHNodeProperties): """Properties for GHRunnerGroupProperties. Attributes: + scope: Whether the runner group is enterprise or organization scoped. group_id: The GitHub runner group ID. group_name: The runner group display name. visibility: Which repositories can use this group: `all`, `private`, or `selected`. @@ -27,11 +29,14 @@ class GHRunnerGroupProperties(GHNodeProperties): restricted_to_workflows: Whether access is restricted to selected workflows. selected_workflows: JSON array of selected workflows, if configured. runners_url: API URL for runners in this group. - environment_name: The name of the environment (GitHub organization). + selected_organizations_url: API URL for organizations assigned to an enterprise group. + environment_name: The name of the environment (GitHub organization or enterprise). query_runners: Query for runners. + query_organizations: Query for organizations assigned to an enterprise group. query_repositories: Query for repositories. """ + scope: str | None = None group_id: int | None = None group_name: str | None = None visibility: str | None = None @@ -41,29 +46,38 @@ class GHRunnerGroupProperties(GHNodeProperties): restricted_to_workflows: bool | None = None selected_workflows: str | None = None runners_url: str | None = None + selected_organizations_url: str | None = None environment_name: str | None = None query_runners: str | None = None + query_organizations: str | None = None query_repositories: str | None = None @app.asset( node=NodeDef( - kind=nk.RUNNER_GROUP, - description="GitHub self-hosted runner group", + kind=nk.ORG_RUNNER_GROUP, + description="GitHub organization-scoped self-hosted runner group", icon="server", properties=GHRunnerGroupProperties, ), edges=[ EdgeDef( start=nk.ORGANIZATION, - end=nk.RUNNER_GROUP, + end=nk.ORG_RUNNER_GROUP, kind=ek.CONTAINS, - description="Organization contains runner group", + description="Organization contains organization runner group", + traversable=False, + ), + EdgeDef( + start=nk.ORG_RUNNER_GROUP, + end=nk.ENTERPRISE_RUNNER_GROUP, + kind=ek.INHERITED_FROM, + description="Organization runner group is inherited from enterprise runner group", traversable=False, ), ], ) -class RunnerGroup(BaseAsset): +class OrgRunnerGroup(BaseAsset): dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} id: int @@ -85,17 +99,23 @@ def org_node_id(self) -> str | None: @property def node_id(self) -> str: - return f"{self.org_node_id}_runner_group_{self.id}" + return runner_group_node_id(self.org_node_id, self.id) @property def as_node(self) -> GHNode: gid = self.node_id + query_runners = ( + f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)-[:GH_Contains]->(:GH_EnterpriseRunner) RETURN p" + if self.inherited + else f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_Contains]->(:GH_OrgRunner) RETURN p" + ) return GHNode( - kinds=[nk.RUNNER_GROUP], + kinds=[nk.ORG_RUNNER_GROUP, nk.RUNNER_GROUP], properties=GHRunnerGroupProperties( name=f"{self.org_login}/{self.name}", displayname=self.name, node_id=gid, + scope="organization", group_id=self.id, group_name=self.name, visibility=self.visibility, @@ -107,8 +127,8 @@ def as_node(self) -> GHNode: runners_url=self.runners_url, environment_name=self.org_login, environmentid=self.org_node_id, - query_runners=f"MATCH p=(:GH_RunnerGroup {{node_id:'{gid}'}})-[:GH_Contains]->(:GH_OrgRunner) RETURN p", - query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunner)<-[:GH_Contains]-(:GH_RunnerGroup {{node_id:'{gid}'}}) RETURN p", + query_runners=query_runners, + query_repositories=f"MATCH p=(:GH_OrgRunnerGroup {{node_id:'{gid}'}})-[:GH_GrantsAccessTo]->(:GH_Repository) RETURN p", ), ) @@ -120,6 +140,150 @@ def edges(self): end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) + if self.inherited: + enterprise_runner_group_node_id = ( + self._lookup.enterprise_runner_group_node_id_for_inherited_org_group( + self.org_node_id, self.name + ) + ) + if enterprise_runner_group_node_id: + yield Edge( + kind=ek.INHERITED_FROM, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath( + value=enterprise_runner_group_node_id, + match_by="id", + ), + properties=EdgeProperties(traversable=False), + ) + + +@app.asset( + node=NodeDef( + kind=nk.ENTERPRISE_RUNNER_GROUP, + description="GitHub enterprise-scoped self-hosted runner group", + icon="server", + properties=GHRunnerGroupProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.ENTERPRISE_RUNNER_GROUP, + kind=ek.CONTAINS, + description="Enterprise contains enterprise runner group", + traversable=False, + ), + EdgeDef( + start=nk.ENTERPRISE_RUNNER_GROUP, + end=nk.ORGANIZATION, + kind=ek.ASSIGNED_TO, + description="Enterprise runner group is assigned to organization", + traversable=False, + ), + ], +) +class EnterpriseRunnerGroup(BaseAsset): + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + id: int + name: str + visibility: str | None = None + default: bool | None = None + allows_public_repositories: bool | None = None + restricted_to_workflows: bool | None = None + selected_workflows: list[str] | None = None + runners_url: str | None = None + selected_organizations_url: str | None = None + + # Additional + enterprise_node_id: str + enterprise_slug: str + + @property + def node_id(self) -> str: + return runner_group_node_id(self.enterprise_node_id, self.id) + + @property + def as_node(self) -> GHNode: + gid = self.node_id + return GHNode( + kinds=[nk.ENTERPRISE_RUNNER_GROUP, nk.RUNNER_GROUP], + properties=GHRunnerGroupProperties( + name=f"{self.enterprise_slug}/{self.name}", + displayname=self.name, + node_id=gid, + scope="enterprise", + group_id=self.id, + group_name=self.name, + visibility=self.visibility, + default=self.default, + allows_public_repositories=self.allows_public_repositories, + restricted_to_workflows=self.restricted_to_workflows, + selected_workflows=json.dumps(self.selected_workflows or []), + runners_url=self.runners_url, + selected_organizations_url=self.selected_organizations_url, + environment_name=self.enterprise_slug, + environmentid=self.enterprise_node_id, + query_runners=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_Contains]->(:GH_EnterpriseRunner) RETURN p", + query_organizations=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})-[:GH_AssignedTo]->(:GH_Organization) RETURN p", + query_repositories=f"MATCH p=(:GH_EnterpriseRunnerGroup {{node_id:'{gid}'}})<-[:GH_InheritedFrom]-(:GH_OrgRunnerGroup)-[:GH_GrantsAccessTo]->(:GH_Repository) RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + if self.visibility == "all": + for (organization_node_id,) in self._lookup.enterprise_organization_node_ids( + self.enterprise_node_id + ): + yield Edge( + kind=ek.ASSIGNED_TO, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=organization_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + +@app.asset( + edges=[ + EdgeDef( + start=nk.ENTERPRISE_RUNNER_GROUP, + end=nk.ORGANIZATION, + kind=ek.ASSIGNED_TO, + description="Enterprise runner group is assigned to organization", + traversable=False, + ), + ], +) +class EnterpriseRunnerGroupOrganization(BaseAsset): + node_id: str + login: str | None = None + runner_group_id: int + enterprise_node_id: str + enterprise_slug: str + + @property + def as_node(self): + return None + + @property + def enterprise_runner_group_node_id(self) -> str: + return runner_group_node_id(self.enterprise_node_id, self.runner_group_id) + + @property + def edges(self): + yield Edge( + kind=ek.ASSIGNED_TO, + start=EdgePath(value=self.enterprise_runner_group_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) @dataclass @@ -127,7 +291,7 @@ class GHRunnerProperties(GHNodeProperties): """Properties for GHRunnerProperties. Attributes: - scope: Whether the runner is organization or repository scoped. + scope: Whether the runner is enterprise, organization, or repository scoped. runner_id: The GitHub runner ID. os: The runner operating system. status: The runner status. @@ -189,7 +353,7 @@ def org_node_id(self) -> str | None: @property def node_id(self) -> str: - return f"{self.org_node_id}_org_runner_{self.id}" + return runner_node_id(self.org_node_id, self.id) @property def as_node(self) -> GHNode: @@ -197,7 +361,7 @@ def as_node(self) -> GHNode: return GHNode( kinds=[nk.ORG_RUNNER, nk.RUNNER], properties=GHRunnerProperties( - name=self.name, + name=f"{self.org_login}/{self.name}", displayname=self.name, node_id=rid, scope="organization", @@ -209,7 +373,7 @@ def as_node(self) -> GHNode: labels=json.dumps(self.labels), environment_name=self.org_login, environmentid=self.org_node_id, - query_group=f"MATCH p=(:GH_RunnerGroup)-[:GH_Contains]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", + query_group=f"MATCH p=(:GH_OrgRunnerGroup)-[:GH_Contains]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", ), ) @@ -219,13 +383,167 @@ def edges(self): return [] +@app.asset( + node=NodeDef( + kind=nk.ENTERPRISE_RUNNER, + description="GitHub enterprise-scoped self-hosted runner", + icon="microchip", + properties=GHRunnerProperties, + ) +) +class EnterpriseRunner(BaseAsset): + id: int + name: str + os: str | None = None + status: str | None = None + busy: bool | None = None + ephemeral: bool | None = None + labels: list[dict] = Field(default_factory=list) + + # Additional + enterprise_node_id: str + enterprise_slug: str + + @property + def node_id(self) -> str: + return runner_node_id(self.enterprise_node_id, self.id) + + @property + def as_node(self) -> GHNode: + rid = self.node_id + return GHNode( + kinds=[nk.ENTERPRISE_RUNNER, nk.RUNNER], + properties=GHRunnerProperties( + name=f"{self.enterprise_slug}/{self.name}", + displayname=self.name, + node_id=rid, + scope="enterprise", + runner_id=self.id, + os=self.os, + status=self.status, + busy=self.busy, + ephemeral=self.ephemeral, + labels=json.dumps(self.labels), + environment_name=self.enterprise_slug, + environmentid=self.enterprise_node_id, + query_group=f"MATCH p=(:GH_EnterpriseRunnerGroup)-[:GH_Contains]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", + query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_EnterpriseRunner {{node_id:'{rid}'}}) RETURN p", + ), + ) + + @property + def edges(self): + return [] + + +@app.asset( + edges=[ + EdgeDef( + start=nk.ORG_RUNNER_GROUP, + end=nk.REPOSITORY, + kind=ek.GRANTS_ACCESS_TO, + description="Organization runner group grants repository access to runners", + traversable=False, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.ENTERPRISE_RUNNER, + kind=ek.CAN_USE_RUNNER, + description="Repository can dispatch jobs to inherited enterprise runner", + traversable=False, + ), + ], +) +class OrgRunnerGroupAccess(BaseAsset): + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + runner_group_id: int + runner_group_name: str + runner_group_visibility: str | None = None + inherited: bool | None = None + accessible_repo_node_ids: list[str] = Field(default_factory=list) + + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + + @property + def runner_group_node_id(self) -> str: + return runner_group_node_id(self.org_node_id, self.runner_group_id) + + @property + def as_node(self): + return None + + @property + def repository_node_ids(self): + if self.runner_group_visibility == "all": + return self._lookup.repository_node_ids_for_org(self.org_login) + if self.runner_group_visibility == "private": + return self._lookup.private_repository_node_ids_for_org(self.org_login) + return [(repo_node_id,) for repo_node_id in self.accessible_repo_node_ids] + + def _inherited_can_use_runner_query( + self, repository_node_id: str, runner_node_id: str + ) -> str: + return ( + f"MATCH p=(:GH_Repository {{node_id:'{repository_node_id}'}})" + f"<-[:GH_GrantsAccessTo]-(:GH_OrgRunnerGroup {{node_id:'{self.runner_group_node_id}'}})" + "-[:GH_InheritedFrom]->(:GH_EnterpriseRunnerGroup)" + f"-[:GH_Contains]->(:GH_EnterpriseRunner {{node_id:'{runner_node_id}'}}) RETURN p" + ) + + @property + def _grants_access_to_edges(self): + for (repo_node_id,) in self.repository_node_ids: + yield Edge( + kind=ek.GRANTS_ACCESS_TO, + start=EdgePath(value=self.runner_group_node_id, match_by="id"), + end=EdgePath(value=repo_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + @property + def _inherited_can_use_runner_edges(self): + if not self.inherited: + return + + runner_node_ids = ( + self._lookup.enterprise_runner_node_ids_for_inherited_org_group( + self.org_node_id, self.runner_group_name + ) + ) + for (repo_node_id,) in self.repository_node_ids: + for (enterprise_runner_node_id,) in runner_node_ids: + yield Edge( + kind=ek.CAN_USE_RUNNER, + start=EdgePath(value=repo_node_id, match_by="id"), + end=EdgePath(value=enterprise_runner_node_id, match_by="id"), + properties=GHEdgeProperties( + traversable=False, + composed=True, + query_composition=self._inherited_can_use_runner_query( + repo_node_id, enterprise_runner_node_id + ), + ), + ) + + @property + def edges(self): + yield from self._grants_access_to_edges + yield from self._inherited_can_use_runner_edges + + @app.asset( edges=[ EdgeDef( - start=nk.RUNNER_GROUP, + start=nk.ORG_RUNNER_GROUP, end=nk.ORG_RUNNER, kind=ek.CONTAINS, - description="Runner group contains organization runner", + description="Organization runner group contains organization runner", traversable=False, ), EdgeDef( @@ -239,6 +557,7 @@ def edges(self): ) class OrgRunnerGroupMembership(BaseAsset): runner_group_id: int + runner_group_name: str | None = None runner_id: int runner_group_visibility: str | None = None accessible_repo_node_ids: list[str] = Field(default_factory=list) @@ -256,16 +575,24 @@ def as_node(self): @property def _runner_node_id(self): - return f"{self.org_node_id}_org_runner_{self.runner_id}" + return runner_node_id(self.org_node_id, self.runner_id) + + @property + def _runner_group_node_id(self): + return runner_group_node_id(self.org_node_id, self.runner_group_id) + + def _can_use_runner_query(self, repository_node_id: str) -> str: + return ( + f"MATCH p=(:GH_Repository {{node_id:'{repository_node_id}'}})" + f"<-[:GH_GrantsAccessTo]-(:GH_OrgRunnerGroup {{node_id:'{self._runner_group_node_id}'}})" + f"-[:GH_Contains]->(:GH_OrgRunner {{node_id:'{self._runner_node_id}'}}) RETURN p" + ) @property def _contains_edge(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath( - value=f"{self.org_node_id}_runner_group_{self.runner_group_id}", - match_by="id", - ), + start=EdgePath(value=self._runner_group_node_id, match_by="id"), end=EdgePath(value=self._runner_node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) @@ -288,7 +615,11 @@ def _can_use_runner_edges(self): kind=ek.CAN_USE_RUNNER, start=EdgePath(value=repo_node_id, match_by="id"), end=EdgePath(value=self._runner_node_id, match_by="id"), - properties=EdgeProperties(traversable=False), + properties=GHEdgeProperties( + traversable=False, + composed=True, + query_composition=self._can_use_runner_query(repo_node_id), + ), ) @property @@ -297,6 +628,47 @@ def edges(self): yield from self._contains_edge +@app.asset( + edges=[ + EdgeDef( + start=nk.ENTERPRISE_RUNNER_GROUP, + end=nk.ENTERPRISE_RUNNER, + kind=ek.CONTAINS, + description="Enterprise runner group contains enterprise runner", + traversable=False, + ), + ], +) +class EnterpriseRunnerGroupMembership(BaseAsset): + runner_group_id: int + runner_id: int + + # Additional + enterprise_node_id: str + enterprise_slug: str + + @property + def as_node(self): + return None + + @property + def _runner_group_node_id(self) -> str: + return runner_group_node_id(self.enterprise_node_id, self.runner_group_id) + + @property + def _runner_node_id(self) -> str: + return runner_node_id(self.enterprise_node_id, self.runner_id) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self._runner_group_node_id, match_by="id"), + end=EdgePath(value=self._runner_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + @app.asset( node=NodeDef( kind=nk.REPO_RUNNER, @@ -342,7 +714,7 @@ def org_node_id(self) -> str | None: @property def node_id(self) -> str: - return f"{self.repository_node_id}_repo_runner_{self.id}" + return runner_node_id(self.repository_node_id, self.id) @property def as_node(self) -> GHNode: @@ -350,7 +722,7 @@ def as_node(self) -> GHNode: return GHNode( kinds=[nk.REPO_RUNNER, nk.RUNNER], properties=GHRunnerProperties( - name=self.name, + name=f"{self.repository_full_name}/{self.name}", displayname=self.name, node_id=rid, scope="repository", diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 562d019..0a6da47 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -19,6 +19,10 @@ EnterpriseAdmin, EnterpriseManagedUser, EnterpriseOrganization, + EnterpriseRunner, + EnterpriseRunnerGroup, + EnterpriseRunnerGroupMembership, + EnterpriseRunnerGroupOrganization, EnterpriseRole, EnterpriseRoleTeam, EnterpriseRoleUser, @@ -280,6 +284,161 @@ def enterprise_teams(enterprise_data: Enterprise, ctx: SourceContext): } +@app.transformer( + name="enterprise_runner_groups", + columns=EnterpriseRunnerGroup, + parallelized=True, +) +def enterprise_runner_groups(enterprise_data: Enterprise, ctx: SourceContext): + client = ctx.sso_client + if not client: + logger.info( + "Skipping enterprise_runner_groups for enterprise '%s': no PAT-backed client configured", + ctx.enterprise_name, + ) + return + + try: + for page in client.paginate( + f"/enterprises/{ctx.enterprise_name}/actions/runner-groups", + params={"per_page": 100}, + data_selector="runner_groups", + ): + for group in page: + yield { + **group, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + except Exception as e: + logger.error( + f"Error in resource 'enterprise_runner_groups' processing enterprise '{ctx.enterprise_name}': {e}", + extra={"resource": "enterprise_runner_groups", "phase": "resource_iteration"}, + ) + return + + +@app.transformer( + name="enterprise_runners", + columns=EnterpriseRunner, + parallelized=True, +) +def enterprise_runners(enterprise_data: Enterprise, ctx: SourceContext): + client = ctx.sso_client + if not client: + logger.info( + "Skipping enterprise_runners for enterprise '%s': no PAT-backed client configured", + ctx.enterprise_name, + ) + return + + try: + for page in client.paginate( + f"/enterprises/{ctx.enterprise_name}/actions/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in page: + yield { + **runner, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + except Exception as e: + logger.error( + f"Error in resource 'enterprise_runners' processing enterprise '{ctx.enterprise_name}': {e}", + extra={"resource": "enterprise_runners", "phase": "resource_iteration"}, + ) + return + + +@app.transformer( + name="enterprise_runner_group_memberships", + columns=EnterpriseRunnerGroupMembership, + parallelized=True, +) +def enterprise_runner_group_memberships( + group: EnterpriseRunnerGroup, ctx: SourceContext +): + client = ctx.sso_client + if not client: + logger.info( + "Skipping enterprise_runner_group_memberships for enterprise '%s': no PAT-backed client configured", + ctx.enterprise_name, + ) + return + + try: + for page in client.paginate( + f"/enterprises/{ctx.enterprise_name}/actions/runner-groups/{group.id}/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in page: + yield { + "runner_group_id": group.id, + "runner_id": runner["id"], + "enterprise_node_id": group.enterprise_node_id, + "enterprise_slug": group.enterprise_slug, + } + except Exception as e: + logger.error( + f"Error in resource 'enterprise_runner_group_memberships' processing enterprise '{ctx.enterprise_name}': {e}", + extra={ + "resource": "enterprise_runner_group_memberships", + "phase": "resource_iteration", + }, + ) + return + + +@app.transformer( + name="enterprise_runner_group_organizations", + columns=EnterpriseRunnerGroupOrganization, + parallelized=True, +) +def enterprise_runner_group_organizations( + group: EnterpriseRunnerGroup, ctx: SourceContext +): + if group.visibility != "selected": + return + + client = ctx.sso_client + if not client: + logger.info( + "Skipping enterprise_runner_group_organizations for enterprise '%s': no PAT-backed client configured", + ctx.enterprise_name, + ) + return + + try: + for page in client.paginate( + f"/enterprises/{ctx.enterprise_name}/actions/runner-groups/{group.id}/organizations", + params={"per_page": 100}, + data_selector="organizations", + ): + for organization in page: + node_id = organization.get("node_id") + if not node_id: + continue + yield { + "node_id": node_id, + "login": organization.get("login"), + "runner_group_id": group.id, + "enterprise_node_id": group.enterprise_node_id, + "enterprise_slug": group.enterprise_slug, + } + except Exception as e: + logger.error( + f"Error in resource 'enterprise_runner_group_organizations' processing enterprise '{ctx.enterprise_name}': {e}", + extra={ + "resource": "enterprise_runner_group_organizations", + "phase": "resource_iteration", + }, + ) + return + + @app.transformer( name="enterprise_team_roles", columns=EnterpriseTeamRole, parallelized=True ) @@ -627,6 +786,7 @@ def enterprise_resources(ctx: SourceContext): members_resource = enterprise_members(ctx) teams_resource = enterprise_teams(ctx) roles_resource = enterprise_roles(ctx) + runner_groups_resource = enterprise_runner_groups(ctx) resources = [ enterprise_resource, enterprise_resource | organizations_resource, @@ -652,6 +812,14 @@ def enterprise_resources(ctx: SourceContext): enterprise_resource | saml_resource | enterprise_saml_assertion_consumer_service(ctx), enterprise_resource | saml_resource | enterprise_saml_issuer(ctx), enterprise_resource | saml_resource | enterprise_external_identity(ctx), + enterprise_resource | runner_groups_resource, + enterprise_resource | enterprise_runners(ctx), + enterprise_resource + | runner_groups_resource + | enterprise_runner_group_memberships(ctx), + enterprise_resource + | runner_groups_resource + | enterprise_runner_group_organizations(ctx), ] ) diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 24a9692..6093c5c 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -40,6 +40,8 @@ OrgRoleMember, OrgRoleTeam, OrgRunner, + OrgRunnerGroup, + OrgRunnerGroupAccess, OrgRunnerGroupMembership, OrgSecret, OrgVariable, @@ -54,7 +56,6 @@ Repository, RepositoryQL, RepoVariable, - RunnerGroup, SamlProvider, SamlServiceProvider, SamlAssertionConsumerService, @@ -1144,7 +1145,7 @@ def environments(repo: Repository, ctx: SourceContext): } -@app.resource(name="runner_groups", columns=RunnerGroup, parallelized=True) +@app.resource(name="runner_groups", columns=OrgRunnerGroup, parallelized=True) def runner_groups(ctx: SourceContext): for org in ctx.organizations: org_name = org.org_name @@ -1193,35 +1194,71 @@ def org_runners(ctx: SourceContext): @app.transformer( - name="org_runner_group_memberships", - columns=OrgRunnerGroupMembership, + name="org_runner_group_access", + columns=OrgRunnerGroupAccess, parallelized=True, ) -def org_runner_group_memberships( - group: RunnerGroup, ctx: SourceContext, repos: list | None = None -): +def org_runner_group_access(group: OrgRunnerGroup, ctx: SourceContext): org_name = group.org_login client = _client_for_org(ctx, org_name) group_row = { "id": group.id, "visibility": group.visibility, } - accessible_repo_node_ids = _selected_runner_group_repo_node_ids( - group_row, client, org_name - ) - for runner_page in client.paginate( - f"/orgs/{org_name}/actions/runner-groups/{group.id}/runners", - params={"per_page": 100}, - data_selector="runners", - ): - for runner in runner_page: - yield { - "runner_group_id": group.id, - "runner_id": runner["id"], - "runner_group_visibility": group.visibility, - "accessible_repo_node_ids": accessible_repo_node_ids, - "org_login": org_name, - } + yield { + "runner_group_id": group.id, + "runner_group_name": group.name, + "runner_group_visibility": group.visibility, + "inherited": group.inherited, + "accessible_repo_node_ids": _selected_runner_group_repo_node_ids( + group_row, client, org_name + ), + "org_login": org_name, + } + + +@app.transformer( + name="org_runner_group_memberships", + columns=OrgRunnerGroupMembership, + parallelized=True, +) +def org_runner_group_memberships( + access: OrgRunnerGroupAccess | dict[str, Any], + ctx: SourceContext, + repos: list | None = None, +): + if isinstance(access, dict): + access = OrgRunnerGroupAccess.model_validate(access) + + if access.inherited: + return + + org_name = access.org_login + client = _client_for_org(ctx, org_name) + try: + for runner_page in client.paginate( + f"/orgs/{org_name}/actions/runner-groups/{access.runner_group_id}/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in runner_page: + yield { + "runner_group_id": access.runner_group_id, + "runner_group_name": access.runner_group_name, + "runner_id": runner["id"], + "runner_group_visibility": access.runner_group_visibility, + "accessible_repo_node_ids": access.accessible_repo_node_ids, + "org_login": org_name, + } + except requests.RequestException as e: + logger.error( + f"Error in resource 'org_runner_group_memberships' processing organization '{org_name}': {e}", + extra={ + "resource": "org_runner_group_memberships", + "phase": "resource_iteration", + }, + ) + return @app.transformer(name="repo_runners", columns=RepoRunner, parallelized=True) @@ -1912,6 +1949,7 @@ def organization_resources(ctx: SourceContext): repositories_graphql_resource = repositories_graphql(ctx) app_installs_resource = app_installations(ctx) runner_groups_resource = runner_groups(ctx) + runner_group_access_resource = runner_groups_resource | org_runner_group_access(ctx) branch_prot_rules_resource = ( repositories_graphql_resource | branch_protection_rules(ctx) ) @@ -1960,7 +1998,8 @@ def organization_resources(ctx: SourceContext): environments_resource | environment_branch_policies(ctx), runner_groups_resource, org_runners(ctx), - runner_groups_resource | org_runner_group_memberships(ctx), + runner_group_access_resource, + runner_group_access_resource | org_runner_group_memberships(ctx), personal_access_tokens_resource, personal_access_tokens_resource | pat_repo_access(ctx), organization_secrets_resource, diff --git a/src/openhound_github/runner_ids.py b/src/openhound_github/runner_ids.py new file mode 100644 index 0000000..ba80e22 --- /dev/null +++ b/src/openhound_github/runner_ids.py @@ -0,0 +1,10 @@ +def runner_group_node_id(scope_node_id: str | None, runner_group_id: int) -> str: + if scope_node_id is None: + raise ValueError("scope_node_id is required for runner group node IDs") + return f"{scope_node_id}_runner_group_{runner_group_id}" + + +def runner_node_id(scope_node_id: str | None, runner_id: int) -> str: + if scope_node_id is None: + raise ValueError("scope_node_id is required for runner node IDs") + return f"{scope_node_id}_runner_{runner_id}" diff --git a/src/openhound_github/transforms.py b/src/openhound_github/transforms.py index 5aabf66..a86ee9f 100644 --- a/src/openhound_github/transforms.py +++ b/src/openhound_github/transforms.py @@ -52,6 +52,26 @@ def ensure_optional_input_tables( variable_name VARCHAR, repository_node_id VARCHAR ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise_organizations ( + id VARCHAR, + enterprise_node_id VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise_runner_groups ( + id BIGINT, + name VARCHAR, + visibility VARCHAR, + enterprise_node_id VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise_runner_group_organizations ( + node_id VARCHAR, + runner_group_id BIGINT, + enterprise_node_id VARCHAR + ); + CREATE TABLE IF NOT EXISTS {schema}.enterprise_runner_group_memberships ( + runner_group_id BIGINT, + runner_id BIGINT, + enterprise_node_id VARCHAR + ); """) con.execute(f""" ALTER TABLE {schema}.branches @@ -99,6 +119,34 @@ def ensure_optional_input_tables( ADD COLUMN IF NOT EXISTS variable_name VARCHAR; ALTER TABLE {schema}.selected_organization_variables ADD COLUMN IF NOT EXISTS repository_node_id VARCHAR; + + ALTER TABLE {schema}.enterprise_organizations + ADD COLUMN IF NOT EXISTS id VARCHAR; + ALTER TABLE {schema}.enterprise_organizations + ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; + + ALTER TABLE {schema}.enterprise_runner_groups + ADD COLUMN IF NOT EXISTS id BIGINT; + ALTER TABLE {schema}.enterprise_runner_groups + ADD COLUMN IF NOT EXISTS name VARCHAR; + ALTER TABLE {schema}.enterprise_runner_groups + ADD COLUMN IF NOT EXISTS visibility VARCHAR; + ALTER TABLE {schema}.enterprise_runner_groups + ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; + + ALTER TABLE {schema}.enterprise_runner_group_organizations + ADD COLUMN IF NOT EXISTS node_id VARCHAR; + ALTER TABLE {schema}.enterprise_runner_group_organizations + ADD COLUMN IF NOT EXISTS runner_group_id BIGINT; + ALTER TABLE {schema}.enterprise_runner_group_organizations + ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; + + ALTER TABLE {schema}.enterprise_runner_group_memberships + ADD COLUMN IF NOT EXISTS runner_group_id BIGINT; + ALTER TABLE {schema}.enterprise_runner_group_memberships + ADD COLUMN IF NOT EXISTS runner_id BIGINT; + ALTER TABLE {schema}.enterprise_runner_group_memberships + ADD COLUMN IF NOT EXISTS enterprise_node_id VARCHAR; """) # TODO: diff --git a/tests/test_enterprise_resources.py b/tests/test_enterprise_resources.py index 9322e18..81a8c02 100644 --- a/tests/test_enterprise_resources.py +++ b/tests/test_enterprise_resources.py @@ -6,6 +6,10 @@ enterprise, enterprise_external_identity, enterprise_organizations, + enterprise_runner_group_memberships, + enterprise_runner_group_organizations, + enterprise_runner_groups, + enterprise_runners, enterprise_saml_provider, ) @@ -159,3 +163,155 @@ def test_enterprise_external_identity_logs_and_returns_on_pagination_failure( in message for message in caplog.messages ) + + +def test_enterprise_runner_groups_use_pat_backed_client() -> None: + app_client = _FakeClient(payload={}) + pat_client = _FakeClient( + payload={}, + pages=[ + [ + { + "id": 2, + "name": "enterprise-runners", + "visibility": "selected", + } + ] + ], + ) + ctx = SourceContext( + client=app_client, + sso_client=pat_client, + enterprise_name="acme", + ) + enterprise_data = SimpleNamespace(id="E_1") + + rows = list(enterprise_runner_groups.__wrapped__(enterprise_data, ctx)) + + assert rows == [ + { + "id": 2, + "name": "enterprise-runners", + "visibility": "selected", + "enterprise_node_id": "E_1", + "enterprise_slug": "acme", + } + ] + assert pat_client.paginate_calls[0][0] == "/enterprises/acme/actions/runner-groups" + assert app_client.paginate_calls == [] + + +def test_enterprise_runners_and_memberships_use_enterprise_scope() -> None: + pat_client = _FakeClient( + payload={}, + pages=[[{"id": 9, "name": "runner-1"}]], + ) + ctx = SourceContext( + client=_FakeClient(payload={}), + sso_client=pat_client, + enterprise_name="acme", + ) + enterprise_data = SimpleNamespace(id="E_1") + + runner_rows = list(enterprise_runners.__wrapped__(enterprise_data, ctx)) + + assert runner_rows == [ + { + "id": 9, + "name": "runner-1", + "enterprise_node_id": "E_1", + "enterprise_slug": "acme", + } + ] + + group = SimpleNamespace( + id=2, + visibility="selected", + enterprise_node_id="E_1", + enterprise_slug="acme", + ) + membership_rows = list( + enterprise_runner_group_memberships.__wrapped__(group, ctx) + ) + + assert membership_rows == [ + { + "runner_group_id": 2, + "runner_id": 9, + "enterprise_node_id": "E_1", + "enterprise_slug": "acme", + } + ] + assert pat_client.paginate_calls == [ + ( + "/enterprises/acme/actions/runners", + {"params": {"per_page": 100}, "data_selector": "runners"}, + ), + ( + "/enterprises/acme/actions/runner-groups/2/runners", + {"params": {"per_page": 100}, "data_selector": "runners"}, + ), + ] + + +def test_enterprise_runner_group_organizations_emit_group_assignments() -> None: + pat_client = _FakeClient( + payload={}, + pages=[ + [ + { + "node_id": "ORG_1", + "login": "acme-org", + } + ] + ], + ) + ctx = SourceContext( + client=_FakeClient(payload={}), + sso_client=pat_client, + enterprise_name="acme", + ) + group = SimpleNamespace( + id=2, + visibility="selected", + enterprise_node_id="E_1", + enterprise_slug="acme", + ) + + rows = list(enterprise_runner_group_organizations.__wrapped__(group, ctx)) + + assert rows == [ + { + "node_id": "ORG_1", + "login": "acme-org", + "runner_group_id": 2, + "enterprise_node_id": "E_1", + "enterprise_slug": "acme", + } + ] + assert pat_client.paginate_calls == [ + ( + "/enterprises/acme/actions/runner-groups/2/organizations", + {"params": {"per_page": 100}, "data_selector": "organizations"}, + ) + ] + + +def test_enterprise_runner_group_organizations_skip_non_selected_groups() -> None: + pat_client = _FakeClient(payload={}, pages=[[{"node_id": "ORG_1"}]]) + ctx = SourceContext( + client=_FakeClient(payload={}), + sso_client=pat_client, + enterprise_name="acme", + ) + group = SimpleNamespace( + id=1, + visibility="all", + enterprise_node_id="E_1", + enterprise_slug="acme", + ) + + rows = list(enterprise_runner_group_organizations.__wrapped__(group, ctx)) + + assert rows == [] + assert pat_client.paginate_calls == [] diff --git a/tests/test_runner_ids.py b/tests/test_runner_ids.py new file mode 100644 index 0000000..6f6c58e --- /dev/null +++ b/tests/test_runner_ids.py @@ -0,0 +1,18 @@ +import pytest + +from openhound_github.runner_ids import runner_group_node_id, runner_node_id + + +def test_runner_group_node_id_requires_scope_node_id() -> None: + with pytest.raises(ValueError, match="scope_node_id is required"): + runner_group_node_id(None, 1) + + +def test_runner_node_id_requires_scope_node_id() -> None: + with pytest.raises(ValueError, match="scope_node_id is required"): + runner_node_id(None, 1) + + +def test_runner_ids_preserve_scoped_identifier_formatting() -> None: + assert runner_group_node_id("ORG_1", 2) == "ORG_1_runner_group_2" + assert runner_node_id("ORG_1", 3) == "ORG_1_runner_3" diff --git a/tests/test_runner_models.py b/tests/test_runner_models.py new file mode 100644 index 0000000..a0acb65 --- /dev/null +++ b/tests/test_runner_models.py @@ -0,0 +1,339 @@ +import duckdb +from types import SimpleNamespace +from unittest.mock import MagicMock + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.lookup import GithubLookup +from openhound_github.models.runner import ( + EnterpriseRunner, + EnterpriseRunnerGroup, + EnterpriseRunnerGroupMembership, + EnterpriseRunnerGroupOrganization, + OrgRunner, + OrgRunnerGroup, + OrgRunnerGroupAccess, + OrgRunnerGroupMembership, + RepoRunner, +) + + +def test_org_runner_group_keeps_generic_runner_group_label() -> None: + group = OrgRunnerGroup(id=1, name="Default", org_login="acme") + group._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") + + node = group.as_node + + assert node.kinds == [nk.ORG_RUNNER_GROUP, nk.RUNNER_GROUP] + assert node.properties.scope == "organization" + assert node.id == "ORG_1_runner_group_1" + + +def test_inherited_org_runner_group_emits_inherited_from_edge() -> None: + group = OrgRunnerGroup(id=1, name="Default", inherited=True, org_login="acme") + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.enterprise_runner_group_node_id_for_inherited_org_group.return_value = ( + "ENT_1_runner_group_2" + ) + group._lookup = lookup + + edges = list(group.edges) + + assert [edge.kind for edge in edges] == [ek.CONTAINS, ek.INHERITED_FROM] + assert edges[1].start.value == "ORG_1_runner_group_1" + assert edges[1].end.value == "ENT_1_runner_group_2" + lookup.enterprise_runner_group_node_id_for_inherited_org_group.assert_called_once_with( + "ORG_1", "Default" + ) + + +def test_non_inherited_org_runner_group_does_not_resolve_enterprise_group() -> None: + group = OrgRunnerGroup(id=1, name="Default", inherited=False, org_login="acme") + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + group._lookup = lookup + + edges = list(group.edges) + + assert [edge.kind for edge in edges] == [ek.CONTAINS] + lookup.enterprise_runner_group_node_id_for_inherited_org_group.assert_not_called() + + +def test_inherited_org_runner_group_uses_enterprise_runner_query() -> None: + group = OrgRunnerGroup(id=1, name="Default", inherited=True, org_login="acme") + group._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") + + assert "GH_InheritedFrom" in group.as_node.properties.query_runners + assert "GH_EnterpriseRunner" in group.as_node.properties.query_runners + + +def test_runner_groups_and_runners_use_scope_owner_prefixes_with_generic_suffixes() -> None: + org_runner = OrgRunner(id=8, name="org-runner-1", org_login="acme") + org_runner._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") + group = EnterpriseRunnerGroup( + id=2, + name="Enterprise Default", + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + runner = EnterpriseRunner( + id=9, + name="enterprise-runner-1", + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + repo_runner = RepoRunner( + id=10, + name="repo-runner-1", + repository_name="repo", + repository_node_id="REPO_1", + repository_full_name="acme/repo", + org_login="acme", + ) + repo_runner._lookup = SimpleNamespace(org_id_for_login=lambda _login: "ORG_1") + + assert org_runner.as_node.kinds == [nk.ORG_RUNNER, nk.RUNNER] + assert org_runner.as_node.properties.scope == "organization" + assert org_runner.as_node.id == "ORG_1_runner_8" + assert org_runner.as_node.properties.name == "acme/org-runner-1" + assert org_runner.as_node.properties.displayname == "org-runner-1" + assert group.as_node.kinds == [nk.ENTERPRISE_RUNNER_GROUP, nk.RUNNER_GROUP] + assert group.as_node.properties.scope == "enterprise" + assert group.as_node.id == "ENT_1_runner_group_2" + + assert runner.as_node.kinds == [nk.ENTERPRISE_RUNNER, nk.RUNNER] + assert runner.as_node.properties.scope == "enterprise" + assert runner.as_node.id == "ENT_1_runner_9" + assert runner.as_node.properties.name == "acme-enterprise/enterprise-runner-1" + assert runner.as_node.properties.displayname == "enterprise-runner-1" + + assert repo_runner.as_node.kinds == [nk.REPO_RUNNER, nk.RUNNER] + assert repo_runner.as_node.properties.scope == "repository" + assert repo_runner.as_node.id == "REPO_1_runner_10" + assert repo_runner.as_node.properties.name == "acme/repo/repo-runner-1" + assert repo_runner.as_node.properties.displayname == "repo-runner-1" + + +def test_enterprise_runner_group_with_all_visibility_assigns_every_enterprise_org() -> None: + group = EnterpriseRunnerGroup( + id=2, + name="Enterprise Default", + visibility="all", + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + lookup = MagicMock() + lookup.enterprise_organization_node_ids.return_value = [("ORG_1",), ("ORG_2",)] + group._lookup = lookup + + edges = list(group.edges) + + assert [edge.kind for edge in edges] == [ + ek.CONTAINS, + ek.ASSIGNED_TO, + ek.ASSIGNED_TO, + ] + assert {edge.end.value for edge in edges[1:]} == {"ORG_1", "ORG_2"} + lookup.enterprise_organization_node_ids.assert_called_once_with("ENT_1") + + +def test_enterprise_runner_group_with_selected_visibility_does_not_infer_orgs() -> None: + group = EnterpriseRunnerGroup( + id=2, + name="Selected Group", + visibility="selected", + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + lookup = MagicMock() + group._lookup = lookup + + edges = list(group.edges) + + assert [edge.kind for edge in edges] == [ek.CONTAINS] + lookup.enterprise_organization_node_ids.assert_not_called() + + +def test_enterprise_runner_group_membership_contains_enterprise_runner() -> None: + membership = EnterpriseRunnerGroupMembership( + runner_group_id=2, + runner_id=9, + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + + edge = next(iter(membership.edges)) + + assert edge.kind == ek.CONTAINS + assert edge.start.value == "ENT_1_runner_group_2" + assert edge.end.value == "ENT_1_runner_9" + + +def test_enterprise_runner_group_organization_assignment_uses_assigned_to() -> None: + assignment = EnterpriseRunnerGroupOrganization( + node_id="ORG_1", + login="acme-org", + runner_group_id=2, + enterprise_node_id="ENT_1", + enterprise_slug="acme-enterprise", + ) + + edge = next(iter(assignment.edges)) + + assert edge.kind == ek.ASSIGNED_TO + assert edge.start.value == "ENT_1_runner_group_2" + assert edge.end.value == "ORG_1" + + +def test_org_runner_group_access_grants_repositories_and_composes_inherited_runners() -> None: + access = OrgRunnerGroupAccess( + runner_group_id=1, + runner_group_name="Default", + runner_group_visibility="all", + inherited=True, + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.repository_node_ids_for_org.return_value = [("REPO_1",)] + lookup.enterprise_runner_node_ids_for_inherited_org_group.return_value = [ + ("ENT_1_runner_9",) + ] + access._lookup = lookup + + edges = list(access.edges) + + assert [edge.kind for edge in edges] == [ + ek.GRANTS_ACCESS_TO, + ek.CAN_USE_RUNNER, + ] + assert edges[0].start.value == "ORG_1_runner_group_1" + assert edges[0].end.value == "REPO_1" + assert edges[1].start.value == "REPO_1" + assert edges[1].end.value == "ENT_1_runner_9" + assert edges[1].properties.composed is True + assert "GH_InheritedFrom" in edges[1].properties.query_composition + + +def test_native_org_runner_group_membership_composes_can_use_runner_through_access() -> None: + membership = OrgRunnerGroupMembership( + runner_group_id=1, + runner_group_name="Default", + runner_id=9, + runner_group_visibility="all", + org_login="acme", + ) + lookup = MagicMock() + lookup.org_id_for_login.return_value = "ORG_1" + lookup.repository_node_ids_for_org.return_value = [("REPO_1",)] + membership._lookup = lookup + + edges = list(membership.edges) + + assert [edge.kind for edge in edges] == [ek.CAN_USE_RUNNER, ek.CONTAINS] + assert edges[0].properties.composed is True + assert "GH_GrantsAccessTo" in edges[0].properties.query_composition + assert edges[1].start.value == "ORG_1_runner_group_1" + assert edges[1].end.value == "ORG_1_runner_9" + + +def test_enterprise_organization_lookup_filters_to_enterprise() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.enterprise_organizations (id VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1'), ('ORG_2', 'ENT_1'), ('ORG_3', 'ENT_2')" + ) + + assert GithubLookup(connection).enterprise_organization_node_ids("ENT_1") == [ + ("ORG_1",), + ("ORG_2",), + ] + + +def test_inherited_org_runner_group_lookup_resolves_all_and_selected_assignments() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.enterprise_organizations (id VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_organizations (node_id VARCHAR, runner_group_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_memberships (runner_group_id BIGINT, runner_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_groups VALUES (1, 'Default', 'all', 'ENT_1'), (2, 'Selected', 'selected', 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_group_organizations VALUES ('ORG_1', 2, 'ENT_1')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_group_memberships VALUES (1, 9, 'ENT_1'), (2, 10, 'ENT_1')" + ) + + lookup = GithubLookup(connection) + + assert ( + lookup.enterprise_runner_group_node_id_for_inherited_org_group( + "ORG_1", "Default" + ) + == "ENT_1_runner_group_1" + ) + assert ( + lookup.enterprise_runner_group_node_id_for_inherited_org_group( + "ORG_1", "Selected" + ) + == "ENT_1_runner_group_2" + ) + assert lookup.enterprise_runner_node_ids_for_inherited_org_group( + "ORG_1", "Default" + ) == [("ENT_1_runner_9",)] + assert lookup.enterprise_runner_node_ids_for_inherited_org_group( + "ORG_1", "Selected" + ) == [("ENT_1_runner_10",)] + + +def test_inherited_org_runner_group_lookup_skips_missing_and_ambiguous_matches() -> None: + connection = duckdb.connect(":memory:") + connection.execute("CREATE SCHEMA github") + connection.execute( + "CREATE TABLE github.enterprise_organizations (id VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_groups (id BIGINT, name VARCHAR, visibility VARCHAR, enterprise_node_id VARCHAR)" + ) + connection.execute( + "CREATE TABLE github.enterprise_runner_group_organizations (node_id VARCHAR, runner_group_id BIGINT, enterprise_node_id VARCHAR)" + ) + connection.execute( + "INSERT INTO github.enterprise_organizations VALUES ('ORG_1', 'ENT_1'), ('ORG_1', 'ENT_2')" + ) + connection.execute( + "INSERT INTO github.enterprise_runner_groups VALUES (1, 'Default', 'all', 'ENT_1'), (2, 'Default', 'all', 'ENT_2')" + ) + + lookup = GithubLookup(connection) + + assert ( + lookup.enterprise_runner_group_node_id_for_inherited_org_group( + "ORG_1", "Missing" + ) + is None + ) + assert ( + lookup.enterprise_runner_group_node_id_for_inherited_org_group( + "ORG_1", "Default" + ) + is None + ) diff --git a/tests/test_runner_resources.py b/tests/test_runner_resources.py new file mode 100644 index 0000000..4b90a50 --- /dev/null +++ b/tests/test_runner_resources.py @@ -0,0 +1,141 @@ +from types import SimpleNamespace + +from dlt.sources.helpers import requests + +from openhound_github.resources.organization import ( + OrgContext, + SourceContext, + org_runner_group_access, + org_runner_group_memberships, +) + + +class _FakeClient: + def __init__(self, pages: dict[str, list[list[dict]]]): + self.pages = pages + self.paginate_calls: list[tuple[str, dict]] = [] + + def paginate(self, path: str, **kwargs): + self.paginate_calls.append((path, kwargs)) + return iter(self.pages.get(path, [])) + + +class _FailingClient(_FakeClient): + def paginate(self, path: str, **kwargs): + self.paginate_calls.append((path, kwargs)) + raise requests.RequestException("runner group membership unavailable") + + +def _ctx(client: _FakeClient) -> SourceContext: + return SourceContext( + client=client, + organizations=[OrgContext(client=client, org_name="acme")], + ) + + +def test_org_runner_group_access_collects_selected_repository_policy() -> None: + client = _FakeClient( + { + "/orgs/acme/actions/runner-groups/1/repositories": [ + [{"node_id": "REPO_1"}, {"node_id": "REPO_2"}] + ] + } + ) + group = SimpleNamespace( + id=1, + name="Default", + visibility="selected", + inherited=True, + org_login="acme", + ) + + rows = list(org_runner_group_access.__wrapped__(group, _ctx(client))) + + assert rows == [ + { + "runner_group_id": 1, + "runner_group_name": "Default", + "runner_group_visibility": "selected", + "inherited": True, + "accessible_repo_node_ids": ["REPO_1", "REPO_2"], + "org_login": "acme", + } + ] + assert client.paginate_calls == [ + ( + "/orgs/acme/actions/runner-groups/1/repositories", + {"params": {"per_page": 100}, "data_selector": "repositories"}, + ) + ] + + +def test_inherited_org_runner_group_access_skips_runner_membership_query() -> None: + client = _FakeClient({}) + access = { + "runner_group_id": 1, + "runner_group_name": "Default", + "runner_group_visibility": "all", + "inherited": True, + "accessible_repo_node_ids": [], + "org_login": "acme", + } + + rows = list(org_runner_group_memberships.__wrapped__(access, _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [] + + +def test_native_org_runner_group_access_collects_runner_memberships() -> None: + client = _FakeClient( + {"/orgs/acme/actions/runner-groups/1/runners": [[{"id": 9}]]} + ) + access = { + "runner_group_id": 1, + "runner_group_name": "Default", + "runner_group_visibility": "all", + "inherited": False, + "accessible_repo_node_ids": [], + "org_login": "acme", + } + + rows = list(org_runner_group_memberships.__wrapped__(access, _ctx(client))) + + assert rows == [ + { + "runner_group_id": 1, + "runner_group_name": "Default", + "runner_id": 9, + "runner_group_visibility": "all", + "accessible_repo_node_ids": [], + "org_login": "acme", + } + ] + assert client.paginate_calls == [ + ( + "/orgs/acme/actions/runner-groups/1/runners", + {"params": {"per_page": 100}, "data_selector": "runners"}, + ) + ] + + +def test_native_org_runner_group_membership_request_failure_yields_no_rows() -> None: + client = _FailingClient({}) + access = { + "runner_group_id": 1, + "runner_group_name": "Default", + "runner_group_visibility": "all", + "inherited": False, + "accessible_repo_node_ids": [], + "org_login": "acme", + } + + rows = list(org_runner_group_memberships.__wrapped__(access, _ctx(client))) + + assert rows == [] + assert client.paginate_calls == [ + ( + "/orgs/acme/actions/runner-groups/1/runners", + {"params": {"per_page": 100}, "data_selector": "runners"}, + ) + ]