From e0b4cbef78097c323a2aafc7a130ca8f4a6628e9 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 13:43:32 -0700 Subject: [PATCH 1/3] BED-9686: qualify organization role names --- src/openhound_github/models/org_role.py | 5 +++-- tests/test_org_role_model.py | 28 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/test_org_role_model.py diff --git a/src/openhound_github/models/org_role.py b/src/openhound_github/models/org_role.py index 9193991..d11561a 100644 --- a/src/openhound_github/models/org_role.py +++ b/src/openhound_github/models/org_role.py @@ -221,11 +221,12 @@ def node_id(self) -> str: @property def as_node(self) -> GHNode: + qualified_name = f"{self.org_login}/{self.name}" return GHNode( kinds=[nk.ORG_ROLE, "GH_Role"], properties=GHOrgRoleProperties( - name=self.name, - displayname=f"{self.org_login}/{self.name}", + name=qualified_name, + displayname=qualified_name, node_id=self.node_id, short_name=self.name, type=self.type, diff --git a/tests/test_org_role_model.py b/tests/test_org_role_model.py new file mode 100644 index 0000000..6bea00c --- /dev/null +++ b/tests/test_org_role_model.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from openhound_github.models.org_role import OrgRole + + +def _role(org_node_id: str, org_login: str) -> OrgRole: + return OrgRole( + id=1, + name="owners", + type="default", + base_role="admin", + created_at=datetime.now(), + org_node_id=org_node_id, + org_login=org_login, + ) + + +def test_org_role_name_is_qualified_by_organization() -> None: + acme_role = _role("ORG_1", "acme") + example_role = _role("ORG_2", "example") + + assert acme_role.as_node.properties.name == "acme/owners" + assert acme_role.as_node.properties.displayname == "acme/owners" + assert acme_role.as_node.properties.short_name == "owners" + + assert example_role.as_node.properties.name == "example/owners" + assert example_role.as_node.properties.displayname == "example/owners" + assert example_role.as_node.properties.short_name == "owners" From d1a331de8bee482d9d84c64fe2ef276bff1f7437 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Tue, 8 Sep 2026 15:14:53 -0700 Subject: [PATCH 2/3] BED-9686: clarify organization role source names --- src/openhound_github/models/org_role.py | 2 +- tests/test_org_role_model.py | 46 +++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/openhound_github/models/org_role.py b/src/openhound_github/models/org_role.py index d11561a..995f696 100644 --- a/src/openhound_github/models/org_role.py +++ b/src/openhound_github/models/org_role.py @@ -201,7 +201,7 @@ class OrgRole(BaseAsset): # node_id: int = Field(alias="id") id: int - name: str # full: "my-org/owners" + name: str # role-only name from the API, e.g. "owners" or "Custom Role Manager" created_at: datetime updated_at: datetime | None = None organization: Organization | None = None diff --git a/tests/test_org_role_model.py b/tests/test_org_role_model.py index 6bea00c..f27be23 100644 --- a/tests/test_org_role_model.py +++ b/tests/test_org_role_model.py @@ -1,13 +1,17 @@ from datetime import datetime from openhound_github.models.org_role import OrgRole +from openhound_github.models.org_role_member import OrgRoleMember +from openhound_github.models.org_role_team import OrgRoleTeam -def _role(org_node_id: str, org_login: str) -> OrgRole: +def _role( + org_node_id: str, org_login: str, name: str = "owners", role_type: str = "default" +) -> OrgRole: return OrgRole( id=1, - name="owners", - type="default", + name=name, + type=role_type, base_role="admin", created_at=datetime.now(), org_node_id=org_node_id, @@ -26,3 +30,39 @@ def test_org_role_name_is_qualified_by_organization() -> None: assert example_role.as_node.properties.name == "example/owners" assert example_role.as_node.properties.displayname == "example/owners" assert example_role.as_node.properties.short_name == "owners" + + +def test_custom_org_role_uses_role_only_name_for_assignment_node_ids() -> None: + role = _role("ORG_1", "acme", name="Custom Role Manager", role_type="custom") + member = OrgRoleMember( + id=1, + node_id="USER_1", + login="alice", + type="User", + site_admin=False, + org_role_id=role.id, + org_role_name=role.name, + org_node_id=role.org_node_id, + org_login=role.org_login, + ) + team = OrgRoleTeam( + id=2, + node_id="TEAM_1", + url="https://api.github.com/teams/2", + name="security", + slug="security", + description="Security team", + permission="pull", + members_url="https://api.github.com/teams/2/members{/member}", + repositories_url="https://api.github.com/teams/2/repos", + org_role_id=role.id, + org_role_name=role.name, + org_node_id=role.org_node_id, + org_login=role.org_login, + ) + + assert role.name == "Custom Role Manager" + assert role.node_id == "ORG_1_Custom Role Manager" + assert role.as_node.properties.name == "acme/Custom Role Manager" + assert member.org_role_node_id == role.node_id + assert team.org_role_node_id == role.node_id From 8390738cde7e185de5db6958a2456251c0c0c3f2 Mon Sep 17 00:00:00 2001 From: Jared Atkinson Date: Wed, 9 Sep 2026 11:27:24 -0700 Subject: [PATCH 3/3] BED-9713 persist GitHub database IDs --- descriptions/nodes/GH_Organization.md | 1 + descriptions/nodes/GH_Repository.md | 4 +- src/openhound_github/models/org.py | 4 ++ src/openhound_github/models/repository.py | 13 +++- .../resources/organization.py | 2 + tests/test_repository_rulesets.py | 69 +++++++++++++++++-- tests/test_source_org_canonicalization.py | 4 ++ 7 files changed, 91 insertions(+), 6 deletions(-) diff --git a/descriptions/nodes/GH_Organization.md b/descriptions/nodes/GH_Organization.md index a7d8871..c988a8c 100644 --- a/descriptions/nodes/GH_Organization.md +++ b/descriptions/nodes/GH_Organization.md @@ -13,6 +13,7 @@ Represents a GitHub organization. This is the root node of the graph and serves | `environmentid` | `string` | The identifier of the GitHub environment where this node was collected. | | `last_seen` | `datetime` | The timestamp when this node was last observed during collection. | | `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. | +| `database_id` | `integer` | The organization's numeric GitHub database ID used in immutable OIDC subjects. | | `login` | `string` | The organization's login handle (URL slug). | | `org_name` | `string` | The organization's display name (from the `name` field in the GitHub API). | | `description` | `string` | The organization's description. | diff --git a/descriptions/nodes/GH_Repository.md b/descriptions/nodes/GH_Repository.md index 8c5ffb6..1d7f266 100644 --- a/descriptions/nodes/GH_Repository.md +++ b/descriptions/nodes/GH_Repository.md @@ -16,6 +16,7 @@ For repositories with active workflows, the collector records the applicable def | `last_seen` | `datetime` | The timestamp when this node was last observed during collection. | | `node_id` | `string` | The stable identifier used as the OpenGraph node ID; this is the native GitHub node ID where available. | | `collected` | `boolean` | Collected/generated by OpenHound. | +| `database_id` | `integer` | The repository's numeric GitHub database ID used in immutable OIDC subjects. | | `full_name` | `string` | The fully qualified name (e.g., `org/repo`). | | `private` | `boolean` | Whether the repository is private. | | `html_url` | `string` | URL to the repository on GitHub. | @@ -35,7 +36,8 @@ For repositories with active workflows, the collector records the applicable def | `open_issues` | `integer` | Number of open issues (includes pull requests). | | `watchers` | `integer` | Number of watchers. | | `owner_name` | `string` | The login of the repository owner. | -| `owner_id` | `string` | The owner id property. | +| `owner_id` | `string` | The opaque GraphQL node ID of the repository owner. | +| `owner_database_id` | `integer` | The numeric GitHub database ID of the repository owner used in immutable OIDC subjects. | | `environment_name` | `string` | The name of the environment (GitHub organization). | | `actions_enabled` | `boolean` | Whether GitHub Actions is enabled for this repository. | | `self_hosted_runners_enabled` | `boolean` | Whether the repository may use self-hosted runners. | diff --git a/src/openhound_github/models/org.py b/src/openhound_github/models/org.py index 2f5a224..10039a3 100644 --- a/src/openhound_github/models/org.py +++ b/src/openhound_github/models/org.py @@ -15,6 +15,7 @@ class GHOrganizationProperties(GHNodeProperties): """Organization-specific properties and accordion panel queries. Attributes: + database_id: The organization's numeric GitHub database ID used in immutable OIDC subjects. login: The organization's login handle (URL slug). org_name: The organization's display name (from the `name` field in the GitHub API). description: The organization's description. @@ -88,6 +89,7 @@ class GHOrganizationProperties(GHNodeProperties): collected: The collected property. """ + database_id: int | None = None login: str | None = None org_name: str | None = None description: str | None = None @@ -176,6 +178,7 @@ class Organization(BaseAsset): node_id: str login: str + database_id: int | None = None name: str | None = None description: str | None = None company: str | None = None @@ -245,6 +248,7 @@ def as_node(self) -> GHNode: name=self.login, displayname=self.name or self.login, node_id=oid, + database_id=self.database_id, login=self.login, org_name=self.name or "", description=self.description, diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 1e57b8c..612433c 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -18,6 +18,7 @@ class GHRepositoryProperties(GHNodeProperties): Attributes: collected: Collected/generated by OpenHound + database_id: The repository's numeric GitHub database ID used in immutable OIDC subjects. full_name: The fully qualified name (e.g., `org/repo`). private: Whether the repository is private. html_url: URL to the repository on GitHub. @@ -37,7 +38,8 @@ class GHRepositoryProperties(GHNodeProperties): open_issues: Number of open issues (includes pull requests). watchers: Number of watchers. owner_name: The login of the repository owner. - owner_id: The owner id property. + owner_id: The opaque GraphQL node ID of the repository owner. + owner_database_id: The numeric GitHub database ID of the repository owner used in immutable OIDC subjects. environment_name: The name of the environment (GitHub organization). actions_enabled: Whether GitHub Actions is enabled for this repository. self_hosted_runners_enabled: Whether the repository may use self-hosted runners. @@ -66,6 +68,7 @@ class GHRepositoryProperties(GHNodeProperties): collected: bool = True # TODO: Check owner_node_id + database_id: int | None = None full_name: str | None = None private: bool | None = None html_url: str | None = None @@ -86,6 +89,7 @@ class GHRepositoryProperties(GHNodeProperties): watchers: int | None = None owner_name: str | None = None owner_id: str | None = None + owner_database_id: int | None = None environment_name: str | None = None actions_enabled: bool | None = None self_hosted_runners_enabled: bool | None = None @@ -189,6 +193,7 @@ class Repository(BaseAsset): node_id: str name: str full_name: str + database_id: int | None = None private: bool owner: Owner @@ -226,6 +231,10 @@ def owner_id(self) -> str: def owner_name(self) -> str: return self.owner.login + @property + def owner_database_id(self) -> int: + return self.owner.id + @property def as_node(self) -> GHNode: rid = self.node_id @@ -240,6 +249,7 @@ def as_node(self) -> GHNode: name=self.name, displayname=self.full_name, node_id=rid, + database_id=self.database_id, full_name=self.full_name, private=self.private, html_url=self.html_url, @@ -260,6 +270,7 @@ def as_node(self) -> GHNode: watchers=self.watchers, owner_name=self.owner_name or "", owner_id=self.owner_id or "", + owner_database_id=self.owner_database_id, environment_name=self.org_login, environmentid=self.org_node_id, actions_enabled=self.actions_enabled, diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index eaf4a4d..9f96d22 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -500,6 +500,7 @@ def organizations(ctx: SourceContext): org_data["can_approve_pull_request_reviews"] = workflow_perms.get( "can_approve_pull_request_reviews" ) + org_data["database_id"] = org_data.get("id") org_data["github_deployment_type"] = ctx.deployment_type org_data["ghes_version"] = ctx.ghes_version @@ -998,6 +999,7 @@ def repositories(ctx: SourceContext): ) yield { **repo, + "database_id": repo.get("id"), "actions_enabled": actions_enabled, "self_hosted_runners_enabled": self_hosted_runners_enabled, "org_login": org_name, diff --git a/tests/test_repository_rulesets.py b/tests/test_repository_rulesets.py index 17f00ad..17dd5bf 100644 --- a/tests/test_repository_rulesets.py +++ b/tests/test_repository_rulesets.py @@ -10,6 +10,7 @@ from openhound_github.resources.organization import ( OrgContext, SourceContext, + repositories, repositories_graphql, ) @@ -33,6 +34,35 @@ def post(self, path: str, *, json: dict[str, object]): return response +class _FakeRepositoryRESTClient: + def __init__(self) -> None: + self.get_calls: list[str] = [] + self.paginate_calls: list[str] = [] + + def get(self, path: str): + self.get_calls.append(path) + return type( + "Response", + (), + {"json": lambda _self: {"enabled_repositories": "all"}}, + )() + + def paginate(self, path: str, **_kwargs): + self.paginate_calls.append(path) + return iter( + [ + [ + { + "id": 1296269, + "node_id": "R_1", + "name": "repo", + "full_name": "org/repo", + } + ] + ] + ) + + def _repository_page_data( repository_id: str, repository_name: str, @@ -92,16 +122,17 @@ def _request_pages(client: _FakeClient) -> list[tuple[object, object]]: def _make_repository() -> Repository: return Repository( - id=1, + id=1296269, node_id="R_1", name="repo", full_name="org/repo", + database_id=1296269, private=False, size=0, owner={ - "login": "octocat", - "id": 1, - "node_id": "U_1", + "login": "org", + "id": 123456, + "node_id": "O_1", "avatar_url": "", "gravatar_id": "", "url": "", @@ -145,6 +176,29 @@ def test_repositories_graphql_flattens_branch_ruleset_count() -> None: ] +def test_repositories_preserve_numeric_database_id_from_rest_payload() -> None: + client = _FakeRepositoryRESTClient() + ctx = SourceContext( + client=client, + organizations=[OrgContext(client=client, org_name="org")], + ) + + rows = list(repositories.__wrapped__(ctx)) + + assert rows == [ + { + "id": 1296269, + "node_id": "R_1", + "name": "repo", + "full_name": "org/repo", + "database_id": 1296269, + "actions_enabled": True, + "self_hosted_runners_enabled": True, + "org_login": "org", + } + ] + + def test_repositories_graphql_uses_dedicated_graphql_client_path() -> None: rest_client = _FakeClient() graphql_client = _FakeClient() @@ -400,6 +454,13 @@ def test_repository_node_surfaces_branch_ruleset_presence() -> None: assert node.properties.default_workflow_permissions == "read" assert node.properties.can_approve_pull_request_reviews is False assert node.properties.size == 0 + assert node.properties.database_id == 1296269 + assert node.properties.owner_database_id == 123456 + assert node.properties.node_id == "R_1" + assert node.properties.owner_id == "O_1" + assert [(edge.start.value, edge.end.value) for edge in repo.edges] == [ + ("O_1", "R_1") + ] lookup.repository_branch_ruleset_count.assert_called_once_with("R_1") lookup.repository_workflow_permissions.assert_called_once_with("R_1") diff --git a/tests/test_source_org_canonicalization.py b/tests/test_source_org_canonicalization.py index 7393ca5..d0093d4 100644 --- a/tests/test_source_org_canonicalization.py +++ b/tests/test_source_org_canonicalization.py @@ -60,6 +60,7 @@ def test_organizations_reuses_preflight_org_response() -> None: client = _FakeClient( { "/orgs/spectertst": { + "id": 123456, "login": "SpecterTst", "node_id": "O_kgDOCoV2OQ", }, @@ -78,9 +79,12 @@ def test_organizations_reuses_preflight_org_response() -> None: rows = list(inspect.unwrap(organizations._pipe.gen)(ctx)) assert rows[0]["login"] == "SpecterTst" + assert rows[0]["database_id"] == 123456 assert rows[0]["github_deployment_type"] == "ghes" assert rows[0]["ghes_version"] == "3.22.1" node = Organization(**rows[0]).as_node + assert node.properties.database_id == 123456 + assert node.properties.node_id == "O_kgDOCoV2OQ" assert node.properties.github_deployment_type == "ghes" assert node.properties.ghes_version == "3.22.1" assert client.get_calls == [