Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions descriptions/nodes/GH_Organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 3 additions & 1 deletion descriptions/nodes/GH_Repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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. |
Expand Down
4 changes: 4 additions & 0 deletions src/openhound_github/models/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions src/openhound_github/models/org_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion src/openhound_github/models/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -189,6 +193,7 @@ class Repository(BaseAsset):
node_id: str
name: str
full_name: str
database_id: int | None = None

private: bool
owner: Owner
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/openhound_github/resources/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions tests/test_org_role_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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, name: str = "owners", role_type: str = "default"
) -> OrgRole:
return OrgRole(
id=1,
name=name,
type=role_type,
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"


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
69 changes: 65 additions & 4 deletions tests/test_repository_rulesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from openhound_github.resources.organization import (
OrgContext,
SourceContext,
repositories,
repositories_graphql,
)

Expand All @@ -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,
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down
4 changes: 4 additions & 0 deletions tests/test_source_org_canonicalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def test_organizations_reuses_preflight_org_response() -> None:
client = _FakeClient(
{
"/orgs/spectertst": {
"id": 123456,
"login": "SpecterTst",
"node_id": "O_kgDOCoV2OQ",
},
Expand All @@ -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 == [
Expand Down
Loading