From 0617fad0497b89454f0023e5d0137dc92908d00d Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Wed, 22 Apr 2026 14:43:32 -0700 Subject: [PATCH] Add GitHub runner groups and org/repo runner collection - model GH_RunnerGroup, GH_OrgRunner, and GH_RepoRunner nodes - add GH_CanUseRunner and runner membership edges - collect org runners from the org-level runners endpoint - collect runner group membership separately from runner inventory - capture repo self-hosted runner eligibility and org runner policy metadata --- src/openhound_github/kinds/edges.py | 1 + src/openhound_github/kinds/nodes.py | 6 + src/openhound_github/models/__init__.py | 5 + src/openhound_github/models/org.py | 8 + src/openhound_github/models/repository.py | 13 +- src/openhound_github/models/runner.py | 382 ++++++++++++++++++++++ src/openhound_github/source.py | 163 ++++++++- 7 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 src/openhound_github/models/runner.py diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index 54cce07..cc89c84 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -13,6 +13,7 @@ # Access and capability edges CAN_ACCESS = "GH_CanAccess" +CAN_USE_RUNNER = "GH_CanUseRunner" CAN_CREATE_BRANCH = "GH_CanCreateBranch" CAN_EDIT_PROTECTION = "GH_CanEditProtection" CAN_WRITE_BRANCH = "GH_CanWriteBranch" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index 4e5a2ac..4eb9355 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -30,9 +30,15 @@ REPO_ROLE = "GH_RepoRole" REPO_SECRET = "GH_RepoSecret" REPO_VARIABLE = "GH_RepoVariable" +REPO_RUNNER = "GH_RepoRunner" DEFAULT_ROLE = "GH_RepoRole" +# Runner nodes +RUNNER = "GH_Runner" +RUNNER_GROUP = "GH_RunnerGroup" +ORG_RUNNER = "GH_OrgRunner" + # Security nodes SECRET_SCANNING_ALERT = "GH_SecretScanningAlert" diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 36b0086..b4b1c1d 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -23,6 +23,7 @@ from .repository_role import BaseRepoRole, RepoRole from .repository_secret import RepoSecret from .repository_variable import RepoVariable +from .runner import OrgRunner, OrgRunnerGroupMembership, RepoRunner, RunnerGroup from .saml_provider import SamlProvider from .scim_user import ScimResource from .secret_scanning_alert import SecretScanningAlert @@ -60,6 +61,10 @@ "SelectedOrgVariable", "RepoSecret", "RepoVariable", + "RunnerGroup", + "OrgRunner", + "OrgRunnerGroupMembership", + "RepoRunner", "SecretScanningAlert", "AppInstallation", "BaseRepoRole", diff --git a/src/openhound_github/models/org.py b/src/openhound_github/models/org.py index 8483161..9ab87e4 100644 --- a/src/openhound_github/models/org.py +++ b/src/openhound_github/models/org.py @@ -264,6 +264,12 @@ class GHOrganizationProperties(GHNodeProperties): default=None, metadata={"description": "Whether SHA pinning is required for GitHub Actions."}, ) + self_hosted_runners_enabled_repositories: str | None = field( + default=None, + metadata={ + "description": "Which repositories may use self-hosted runners: `all`, `selected`, or `none`." + }, + ) default_workflow_permissions: str | None = None can_approve_pull_request_reviews: bool | None = None query_organization_roles: str = "" @@ -345,6 +351,7 @@ class Organization(BaseAsset): actions_enabled_repositories: str | None = None actions_allowed_actions: str | None = None actions_sha_pinning_required: bool | None = None + self_hosted_runners_enabled_repositories: str | None = None default_workflow_permissions: str | None = None can_approve_pull_request_reviews: bool | None = None @@ -415,6 +422,7 @@ def as_node(self) -> GHNode: actions_enabled_repositories=self.actions_enabled_repositories, actions_allowed_actions=self.actions_allowed_actions, actions_sha_pinning_required=self.actions_sha_pinning_required, + 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, query_organization_roles=f"MATCH (:GH_Organization {{node_id:'{oid}'}})-[:GH_Contains]->(n:GH_OrgRole) RETURN n", diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index f8472e1..5932687 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -93,6 +93,12 @@ class GHRepositoryProperties(GHNodeProperties): "description": "Whether GitHub Actions is enabled for this repository." }, ) + self_hosted_runners_enabled: bool | None = field( + default=None, + metadata={ + "description": "Whether the repository may use self-hosted runners." + }, + ) secret_scanning: str | None = field( default=None, metadata={ @@ -105,6 +111,7 @@ class GHRepositoryProperties(GHNodeProperties): query_roles: str = "" query_teams: str = "" query_workflows: str = "" + query_runners: str = "" query_environments: str = "" query_secrets: str = "" query_variables: str = "" @@ -206,6 +213,8 @@ class Repository(BaseAsset): forks: int | None = None open_issues: int | None = None watchers: int | None = None + actions_enabled: bool | None = None + self_hosted_runners_enabled: bool | None = None @property def owner_id(self) -> str: @@ -245,7 +254,8 @@ def as_node(self) -> GHNode: owner_id=self.owner_id or "", environment_name=self._lookup.org_login(), environmentid=self._lookup.org_id(), - # actions_enabled=self.actions_enabled, + actions_enabled=self.actions_enabled, + self_hosted_runners_enabled=self.self_hosted_runners_enabled, # secret_scanning=self.secret_scanning, query_branches=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_HasBranch]->(:GH_Branch) RETURN p", query_protected_branches=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_HasBranch]->(:GH_Branch)<-[:GH_ProtectedBy]-(:GH_BranchProtectionRule) RETURN p", @@ -253,6 +263,7 @@ def as_node(self) -> GHNode: query_roles=f"MATCH p=(:GH_RepoRole)-[*1..]->(:GH_Repository {{node_id: '{rid}'}}) RETURN p", query_teams=f"MATCH p=(:GH_Team)-[:GH_MemberOf|GH_HasRole*1..]->(:GH_RepoRole)-[]->(:GH_Repository {{node_id: '{rid}'}}) RETURN p", query_workflows=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasWorkflow]->(w:GH_Workflow) RETURN p", + query_runners=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_CanUseRunner]->(:GH_Runner) RETURN p", query_environments=f"MATCH p=(:GH_Repository {{node_id: '{rid}'}})-[:GH_HasEnvironment]->(:GH_Environment) RETURN p", query_secrets=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasSecret]->(:GH_Secret) RETURN p", query_variables=f"MATCH p=(:GH_Repository {{node_id:'{rid}'}})-[:GH_HasVariable]->(:GH_Variable) RETURN p", diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py new file mode 100644 index 0000000..fef4b8a --- /dev/null +++ b/src/openhound_github/models/runner.py @@ -0,0 +1,382 @@ +import json +from dataclasses import dataclass, field + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +@dataclass +class GHRunnerGroupProperties(GHNodeProperties): + group_id: int | None = field( + default=None, metadata={"description": "The GitHub runner group ID."} + ) + group_name: str = field( + default="", metadata={"description": "The runner group display name."} + ) + visibility: str | None = field( + default=None, + metadata={ + "description": "Which repositories can use this group: `all`, `private`, or `selected`." + }, + ) + default: bool | None = field( + default=None, + metadata={"description": "Whether this is the default runner group."}, + ) + inherited: bool | None = field( + default=None, + metadata={"description": "Whether this runner group is inherited."}, + ) + allows_public_repositories: bool | None = field( + default=None, + metadata={"description": "Whether public repositories may use this group."}, + ) + restricted_to_workflows: bool | None = field( + default=None, + metadata={"description": "Whether access is restricted to selected workflows."}, + ) + selected_workflows: str | None = field( + default=None, + metadata={"description": "JSON array of selected workflows, if configured."}, + ) + runners_url: str | None = field( + default=None, + metadata={"description": "API URL for runners in this group."}, + ) + environment_name: str = field( + default="", + metadata={"description": "The name of the environment (GitHub organization)."}, + ) + query_runners: str = "" + query_repositories: str = "" + + +@app.asset( + node=NodeDef( + kind=nk.RUNNER_GROUP, + description="GitHub self-hosted runner group", + icon="server", + properties=GHRunnerGroupProperties, + ), + edges=[ + EdgeDef( + start=nk.ORGANIZATION, + end=nk.RUNNER_GROUP, + kind=ek.CONTAINS, + description="Organization contains runner group", + traversable=False, + ), + ], +) +class RunnerGroup(BaseAsset): + id: int + name: str + visibility: str | None = None + default: bool | None = None + inherited: 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 + + @property + def node_id(self) -> str: + return f"{self._lookup.org_id()}_runner_group_{self.id}" + + @property + def as_node(self) -> GHNode: + gid = self.node_id + return GHNode( + kinds=[nk.RUNNER_GROUP], + properties=GHRunnerGroupProperties( + name=f"{self._lookup.org_login()}/{self.name}", + displayname=self.name, + node_id=gid, + group_id=self.id, + group_name=self.name, + visibility=self.visibility, + default=self.default, + inherited=self.inherited, + 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, + environment_name=self._lookup.org_login(), + environmentid=self._lookup.org_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", + ), + ) + + @property + def edges(self) -> list[Edge]: + return [ + Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self._lookup.org_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + ] + + +@dataclass +class GHRunnerProperties(GHNodeProperties): + scope: str = field( + default="", + metadata={"description": "Whether the runner is organization or repository scoped."}, + ) + runner_id: int | None = field( + default=None, metadata={"description": "The GitHub runner ID."} + ) + os: str | None = field( + default=None, metadata={"description": "The runner operating system."} + ) + status: str | None = field( + default=None, metadata={"description": "The runner status."} + ) + busy: bool | None = field( + default=None, metadata={"description": "Whether the runner is currently busy."} + ) + ephemeral: bool | None = field( + default=None, + metadata={"description": "Whether the runner is ephemeral."}, + ) + labels: str | None = field( + default=None, metadata={"description": "JSON array of runner labels."} + ) + runner_group_id: int | None = field( + default=None, metadata={"description": "The associated runner group ID."} + ) + runner_group_name: str | None = field( + default=None, metadata={"description": "The associated runner group name."} + ) + runner_group_visibility: str | None = field( + default=None, + metadata={"description": "Runner group visibility when organization scoped."}, + ) + repository_name: str | None = field( + default=None, + metadata={"description": "The repository name for repository-scoped runners."}, + ) + repository_id: str | None = field( + default=None, + metadata={"description": "The repository node_id for repository-scoped runners."}, + ) + repository_full_name: str | None = field( + default=None, + metadata={"description": "The full repository name for repository-scoped runners."}, + ) + environment_name: str = field( + default="", + metadata={"description": "The name of the environment (GitHub organization)."}, + ) + query_group: str = "" + query_repositories: str = "" + + +@app.asset( + node=NodeDef( + kind=nk.ORG_RUNNER, + description="GitHub organization-scoped self-hosted runner", + icon="microchip", + properties=GHRunnerProperties, + ), + edges=[ + EdgeDef( + start=nk.RUNNER_GROUP, + end=nk.ORG_RUNNER, + kind=ek.CONTAINS, + description="Runner group contains organization runner", + traversable=False, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.ORG_RUNNER, + kind=ek.CAN_USE_RUNNER, + description="Repository can dispatch jobs to runner", + traversable=False, + ), + ], +) +class OrgRunner(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) + + @property + def node_id(self) -> str: + return f"{self._lookup.org_id()}_org_runner_{self.id}" + + @property + def as_node(self) -> GHNode: + rid = self.node_id + return GHNode( + kinds=[nk.ORG_RUNNER, nk.RUNNER], + properties=GHRunnerProperties( + name=self.name, + displayname=self.name, + node_id=rid, + scope="organization", + runner_id=self.id, + os=self.os, + status=self.status, + busy=self.busy, + ephemeral=self.ephemeral, + labels=json.dumps(self.labels), + environment_name=self._lookup.org_login(), + environmentid=self._lookup.org_id(), + query_group=f"MATCH p=(:GH_RunnerGroup)-[: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", + ), + ) + + @property + def edges(self) -> list[Edge]: + return [] + + +@app.asset( + edges=[ + EdgeDef( + start=nk.RUNNER_GROUP, + end=nk.ORG_RUNNER, + kind=ek.CONTAINS, + description="Runner group contains organization runner", + traversable=False, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.ORG_RUNNER, + kind=ek.CAN_USE_RUNNER, + description="Repository can dispatch jobs to runner", + traversable=False, + ), + ], +) +class OrgRunnerGroupMembership(BaseAsset): + runner_group_id: int + runner_id: int + accessible_repo_node_ids: list[str] = Field(default_factory=list) + + @property + def as_node(self): + return None + + @property + def edges(self) -> list[Edge]: + runner_node_id = f"{self._lookup.org_id()}_org_runner_{self.runner_id}" + edges = [ + Edge( + kind=ek.CONTAINS, + start=EdgePath( + value=f"{self._lookup.org_id()}_runner_group_{self.runner_group_id}", + match_by="id", + ), + end=EdgePath(value=runner_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + ] + edges.extend( + Edge( + kind=ek.CAN_USE_RUNNER, + start=EdgePath(value=repo_node_id, match_by="id"), + end=EdgePath(value=runner_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + for repo_node_id in self.accessible_repo_node_ids + ) + return edges + + +@app.asset( + node=NodeDef( + kind=nk.REPO_RUNNER, + description="GitHub repository-scoped self-hosted runner", + icon="microchip", + properties=GHRunnerProperties, + ), + edges=[ + EdgeDef( + start=nk.REPOSITORY, + end=nk.REPO_RUNNER, + kind=ek.CONTAINS, + description="Repository contains repository runner", + traversable=False, + ), + EdgeDef( + start=nk.REPOSITORY, + end=nk.REPO_RUNNER, + kind=ek.CAN_USE_RUNNER, + description="Repository can dispatch jobs to repository runner", + traversable=False, + ), + ], +) +class RepoRunner(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) + repository_name: str + repository_node_id: str + repository_full_name: str + + @property + def node_id(self) -> str: + return f"{self.repository_node_id}_repo_runner_{self.id}" + + @property + def as_node(self) -> GHNode: + rid = self.node_id + return GHNode( + kinds=[nk.REPO_RUNNER, nk.RUNNER], + properties=GHRunnerProperties( + name=self.name, + displayname=self.name, + node_id=rid, + scope="repository", + runner_id=self.id, + os=self.os, + status=self.status, + busy=self.busy, + ephemeral=self.ephemeral, + labels=json.dumps(self.labels), + repository_name=self.repository_name, + repository_id=self.repository_node_id, + repository_full_name=self.repository_full_name, + environment_name=self._lookup.org_login(), + environmentid=self._lookup.org_id(), + query_repositories=f"MATCH p=(:GH_Repository {{node_id:'{self.repository_node_id}'}})-[:GH_CanUseRunner]->(:GH_RepoRunner {{node_id:'{rid}'}}) RETURN p", + ), + ) + + @property + def edges(self) -> list[Edge]: + return [ + Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self.repository_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ), + Edge( + kind=ek.CAN_USE_RUNNER, + start=EdgePath(value=self.repository_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ), + ] diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 48675e4..0d1a093 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -45,15 +45,18 @@ OrgRoleTeam, OrgSecret, OrgVariable, + OrgRunnerGroupMembership, PatRepoAccess, PersonalAccessToken, PersonalAccessTokenRequest, + RepoRunner, RepoRole, RepoRoleAssignment, RepoSecret, Repository, RepositoryQL, RepoVariable, + RunnerGroup, SamlProvider, ScimResource, SecretScanningAlert, @@ -64,6 +67,7 @@ TeamRole, User, Workflow, + OrgRunner, ) from openhound_github.models.repo_role_assignment import TEAM_PERMISSION_MAP from openhound_github.models.repository_role import DEFAULT_REPO_ROLES @@ -77,6 +81,38 @@ class SourceContext: org_name: str +def _runner_group_repo_node_ids(group: dict[str, Any], ctx: SourceContext) -> list[str]: + visibility = group.get("visibility") + if visibility == "selected": + repo_node_ids: list[str] = [] + try: + for page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/actions/runner-groups/{group['id']}/repositories", + params={"per_page": 100}, + data_selector="repositories", + ): + repo_node_ids.extend( + repo.get("node_id") for repo in page if repo.get("node_id") + ) + except Exception: + return [] + return repo_node_ids + + repo_node_ids = [] + for page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/repos", params={"per_page": 100} + ): + for repo in page: + if visibility == "all": + repo_node_ids.append(repo["node_id"]) + elif visibility == "private" and repo.get("visibility") in { + "private", + "internal", + }: + repo_node_ids.append(repo["node_id"]) + return repo_node_ids + + @configspec class GithubCredentials(CredentialsConfiguration): org_name: str = None @@ -134,6 +170,9 @@ def organizations(ctx: SourceContext): org_data = ctx.client.get(f"/orgs/{ctx.org_name}").json() actions = ctx.client.get(f"/orgs/{ctx.org_name}/actions/permissions").json() + self_hosted_runners = ctx.client.get( + f"/orgs/{ctx.org_name}/actions/permissions/self-hosted-runners" + ).json() workflow_perms = ctx.client.get( f"/orgs/{ctx.org_name}/actions/permissions/workflow" ).json() @@ -141,6 +180,9 @@ def organizations(ctx: SourceContext): org_data["actions_enabled_repositories"] = actions.get("enabled_repositories") org_data["actions_allowed_actions"] = actions.get("allowed_actions") org_data["actions_sha_pinning_required"] = actions.get("sha_pinning_required") + org_data["self_hosted_runners_enabled_repositories"] = self_hosted_runners.get( + "enabled_repositories" + ) org_data["default_workflow_permissions"] = workflow_perms.get( "default_workflow_permissions" ) @@ -448,11 +490,55 @@ def repositories(ctx: SourceContext): Yields: Repository (Repository): Repository record. """ + actions = ctx.client.get(f"/orgs/{ctx.org_name}/actions/permissions").json() + runner_settings = ctx.client.get( + f"/orgs/{ctx.org_name}/actions/permissions/self-hosted-runners" + ).json() + + enabled_repo_ids: set[str] | None = None + if actions.get("enabled_repositories") == "selected": + enabled_repo_ids = set() + for page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/actions/permissions/repositories", + params={"per_page": 100}, + data_selector="repositories", + ): + enabled_repo_ids.update( + repo["node_id"] for repo in page if repo.get("node_id") + ) + + runner_enabled_repo_ids: set[str] | None = None + if runner_settings.get("enabled_repositories") == "selected": + runner_enabled_repo_ids = set() + for page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/actions/permissions/self-hosted-runners/repositories", + params={"per_page": 100}, + data_selector="repositories", + ): + runner_enabled_repo_ids.update( + repo["node_id"] for repo in page if repo.get("node_id") + ) + for page in ctx.client.paginate( f"/orgs/{ctx.org_name}/repos", params={"per_page": 100} ): for repo in page: - yield repo + repo_node_id = repo.get("node_id") + actions_enabled = actions.get("enabled_repositories") == "all" or ( + enabled_repo_ids is not None and repo_node_id in enabled_repo_ids + ) + self_hosted_runners_enabled = ( + runner_settings.get("enabled_repositories") == "all" + or ( + runner_enabled_repo_ids is not None + and repo_node_id in runner_enabled_repo_ids + ) + ) + yield { + **repo, + "actions_enabled": actions_enabled, + "self_hosted_runners_enabled": self_hosted_runners_enabled, + } @app.transformer( @@ -764,6 +850,75 @@ def environments(repo: Repository, ctx: SourceContext): } +@app.resource(name="runner_groups", columns=RunnerGroup, parallelized=True) +def runner_groups(ctx: SourceContext): + for page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/actions/runner-groups", + params={"per_page": 100}, + data_selector="runner_groups", + ): + for group in page: + yield group + + +@app.resource(name="org_runners", columns=OrgRunner, parallelized=True) +def org_runners(ctx: SourceContext): + for page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/actions/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in page: + yield runner + + +@app.resource( + name="org_runner_group_memberships", + columns=OrgRunnerGroupMembership, + parallelized=True, +) +def org_runner_group_memberships(ctx: SourceContext): + for group_page in ctx.client.paginate( + f"/orgs/{ctx.org_name}/actions/runner-groups", + params={"per_page": 100}, + data_selector="runner_groups", + ): + for group in group_page: + accessible_repo_node_ids = _runner_group_repo_node_ids(group, ctx) + try: + for runner_page in ctx.client.paginate( + f"/orgs/{ctx.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"], + "accessible_repo_node_ids": accessible_repo_node_ids, + } + except Exception: + continue + + +@app.transformer(name="repo_runners", columns=RepoRunner, parallelized=True) +def repo_runners(repo: Repository, ctx: SourceContext): + if not repo.self_hosted_runners_enabled: + return + for page in ctx.client.paginate( + f"/repos/{repo.full_name}/actions/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in page: + yield { + **runner, + "repository_name": repo.name, + "repository_node_id": repo.node_id, + "repository_full_name": repo.full_name, + } + + @app.transformer( name="environment_variables", columns=EnvironmentVariable, parallelized=True ) @@ -1271,6 +1426,8 @@ def retry_policy( teams_resource = teams(ctx) repositories_graphql_resource = repositories_graphql(ctx) app_installs_resource = app_installations(ctx) + runner_groups_resource = runner_groups(ctx) + org_runner_group_memberships_resource = org_runner_group_memberships(ctx) branch_prot_rules_resource = ( repositories_graphql_resource | branch_protection_rules(ctx) ) @@ -1286,6 +1443,7 @@ def retry_policy( repos_resource, repos_resource | repository_roles(repo_roles_base), repos_resource | workflows(ctx), + repos_resource | repo_runners(ctx), repos_resource | repository_secrets(ctx), repos_resource | repository_variables(ctx), repos_resource | repo_role_assignments(ctx, repo_roles_base), @@ -1296,6 +1454,9 @@ def retry_policy( teams_resource, teams_resource | team_members(ctx), teams_resource | team_roles(), + runner_groups_resource, + org_runners(ctx), + org_runner_group_memberships_resource, personal_access_tokens_resource, personal_access_tokens_resource | pat_repo_access(ctx), organization_secrets_resource,