Skip to content
Open
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
86 changes: 86 additions & 0 deletions integrations/github/.port/resources/blueprints.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,79 @@
[
{
"identifier": "githubOrganization",
"description": "This blueprint represents a service in our software catalog",
"title": "Organization",
"icon": "Github",
"schema": {
"properties": {
"login": {
"type": "string",
"title": "Organization Login",
"description": "The GitHub organization login name"
},
"id": {
"type": "number",
"title": "Organization ID",
"description": "GitHub organization ID"
},
"nodeId": {
"type": "string",
"title": "Node ID",
"description": "GitHub GraphQL node ID"
},
"url": {
"type": "string",
"title": "API URL",
"description": "GitHub API URL for the organization"
},
"reposUrl": {
"type": "string",
"title": "Repositories URL",
"description": "URL to organization's repositories"
},
"eventsUrl": {
"type": "string",
"title": "Events URL",
"description": "URL to organization's events"
},
"hooksUrl": {
"type": "string",
"title": "Webhooks URL",
"description": "URL to organization's webhooks"
},
"issuesUrl": {
"type": "string",
"title": "Issues URL",
"description": "URL to organization's issues"
},
"membersUrl": {
"type": "string",
"title": "Members URL",
"description": "URL to organization's members"
},
"publicMembersUrl": {
"type": "string",
"title": "Public Members URL",
"description": "URL to organization's public members"
},
"avatarUrl": {
"type": "string",
"title": "Avatar URL",
"description": "Organization avatar image URL"
},
"description": {
"type": "string",
"title": "Description",
"description": "Organization description"
}
},
"required": []
},
"mirrorProperties": {},
"calculationProperties": {},
"aggregationProperties": {},
"relations": {}
},
{
"identifier": "githubRepository",
"title": "Repository",
Expand Down Expand Up @@ -39,6 +114,17 @@
}
},
"required": []
},
"mirrorProperties": {},
"calculationProperties": {},
"aggregationProperties": {},
"relations": {
"organization": {
"title": "Organization",
"target": "githubOrganization",
"required": false,
"many": false
}
}
},
{
Expand Down
24 changes: 24 additions & 0 deletions integrations/github/.port/resources/port-app-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ deleteDependentEntities: true
createMissingRelatedEntities: true
repositoryType: 'all'
resources:
- kind: organization
selector:
query: 'true'
port:
entity:
mappings:
identifier: .login
title: .login
blueprint: '''githubOrganization'''
properties:
login: .login
id: .id
nodeId: .node_id
url: .url
reposUrl: .repos_url
eventsUrl: .events_url
hooksUrl: .hooks_url
issuesUrl: .issues_url
membersUrl: .members_url
publicMembersUrl: .public_members_url
avatarUrl: .avatar_url
description: if .description then .description else "" end
- kind: repository
selector:
query: 'true'
Expand All @@ -18,6 +40,8 @@ resources:
readme: file://README.md
url: .html_url
language: if .language then .language else "" end
relations:
organization: .owner.login
- kind: pull-request
selector:
query: 'true'
Expand Down
9 changes: 7 additions & 2 deletions integrations/github/.port/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ features:
- type: exporter
section: Git
resources:
- kind: organization
- kind: repository
- kind: folder
- kind: user
Expand Down Expand Up @@ -44,9 +45,13 @@ configurations:
sensitive: true
description: If App ID is passed, then app private key is required.
- name: githubOrganization
required: true
required: false
type: string
description: GitHub organization name
description: Optional GitHub organization name for GitHub App or Fine-grained PAT authentication.
- name: githubMultiOrganizations
required: false
type: array
description: List of GitHub organization names (optional - if not provided, will sync all organizations the personal access token user is a member of) for Classic PAT authentication.
- name: webhookSecret
type: string
description: Optional secret used to verify incoming webhook requests. Ensures that only legitimate events from GitHub are accepted.
Expand Down
13 changes: 13 additions & 0 deletions integrations/github/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

<!-- towncrier release notes start -->

## 3.0.0-beta (2025-10-16)


### Features

- Added multi-organization support for GitHub integration
- Updated all resync functions to iterate through multiple organizations
- Modified webhook processors to include organization context
- Updated exporters to support organization parameters
- Added `organization` as a new resource kind
- Introduced `githubMultiOrganizations` in spec file


## 2.0.1-beta (2025-10-15)


Expand Down
11 changes: 6 additions & 5 deletions integrations/github/github/clients/client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,25 @@
PersonalTokenAuthenticator,
)
from github.clients.auth.github_app_authenticator import GitHubAppAuthenticator
from github.helpers.exceptions import MissingCredentials
from github.helpers.exceptions import MissingCredentials, OrganizationConflictError


class GitHubAuthenticatorFactory:
@staticmethod
def create(
organization: str,
github_host: str,
organization: Optional[str] = None,
token: Optional[str] = None,
app_id: Optional[str] = None,
private_key: Optional[str] = None,
) -> AbstractGitHubAuthenticator:
if token:
logger.debug(
f"Creating Personal Token Authenticator for {organization} on {github_host}"
f"Creating Personal Token Authenticator for select organizations for PAT on {github_host}"
)
return PersonalTokenAuthenticator(token)

if app_id and private_key:
if organization and app_id and private_key:
logger.debug(
f"Creating GitHub App Authenticator for {organization} on {github_host}"
)
Expand Down Expand Up @@ -76,9 +76,10 @@ def get_client(self, client_type: GithubClientType) -> AbstractGithubClient:
logger.error(f"Invalid client type: {client_type}")
raise ValueError(f"Invalid client type: {client_type}")

github_organization = ocean.integration_config["github_organization"]
authenticator = GitHubAuthenticatorFactory.create(
organization=ocean.integration_config["github_organization"],
github_host=ocean.integration_config["github_host"],
organization=github_organization,
token=ocean.integration_config.get("github_token"),
app_id=ocean.integration_config.get("github_app_id"),
private_key=ocean.integration_config.get("github_app_private_key"),
Expand Down
2 changes: 0 additions & 2 deletions integrations/github/github/clients/http/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,10 @@
class AbstractGithubClient(ABC):
def __init__(
self,
organization: str,
github_host: str,
authenticator: "AbstractGitHubAuthenticator",
**kwargs: Any,
) -> None:
self.organization = organization
self.github_host = github_host
self.authenticator = authenticator
self.kwargs = kwargs
Expand Down
25 changes: 23 additions & 2 deletions integrations/github/github/clients/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
from typing import Any, Dict
from typing import Any, Dict, cast
from github.core.options import ListOrganizationOptions
from github.helpers.exceptions import OrganizationRequiredException
from port_ocean.context.ocean import ocean

from github.clients.auth.abstract_authenticator import AbstractGitHubAuthenticator
from integration import GithubPortAppConfig
from port_ocean.context.event import event


def integration_config(authenticator: AbstractGitHubAuthenticator) -> Dict[str, Any]:
return {
"authenticator": authenticator,
"organization": ocean.integration_config["github_organization"],
"github_host": ocean.integration_config["github_host"],
}


def get_github_organizations() -> ListOrganizationOptions:
"""Get the organizations from the integration config."""
organization = ocean.integration_config["github_organization"]
port_app_config = cast(GithubPortAppConfig, event.port_app_config)
return ListOrganizationOptions(
organization=organization,
allowed_multi_organizations=port_app_config.allowed_organizations(),
)


def get_mono_repo_organization(organization: str | None) -> str:
"""Get the organization for a monorepo."""
organization = organization or ocean.integration_config["github_organization"]
if not organization:
raise OrganizationRequiredException("Organization is required")
return organization
43 changes: 27 additions & 16 deletions integrations/github/github/core/exporters/branch_exporter.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,41 @@
import asyncio
from typing import Any
from typing import Any, cast
from github.core.exporters.abstract_exporter import AbstractGithubExporter
from port_ocean.core.ocean_types import ASYNC_GENERATOR_RESYNC_TYPE, RAW_ITEM
from loguru import logger
from github.core.options import ListBranchOptions, SingleBranchOptions
from github.clients.http.rest_client import GithubRestClient
from github.helpers.utils import enrich_with_repository, extract_repo_params
from github.helpers.utils import enrich_with_repository, parse_github_options


class RestBranchExporter(AbstractGithubExporter[GithubRestClient]):

async def fetch_branch(self, repo_name: str, branch_name: str) -> RAW_ITEM:
endpoint = f"{self.client.base_url}/repos/{self.client.organization}/{repo_name}/branches/{branch_name}"
async def fetch_branch(
self, repo_name: str, branch_name: str, organization: str
) -> RAW_ITEM:
endpoint = f"{self.client.base_url}/repos/{organization}/{repo_name}/branches/{branch_name}"
response = await self.client.send_api_request(endpoint)
return response

async def get_resource[
ExporterOptionsT: SingleBranchOptions
](self, options: ExporterOptionsT) -> RAW_ITEM:

repo_name, params = extract_repo_params(dict(options))
repo_name, organization, params = parse_github_options(dict(options))
branch_name = params["branch_name"]
protection_rules = bool(params["protection_rules"])
repo_name = cast(str, repo_name)

response = await self.fetch_branch(repo_name, branch_name)
response = await self.fetch_branch(repo_name, branch_name, organization)

if protection_rules:
response = await self._enrich_branch_with_protection_rules(
repo_name, response
repo_name, response, organization
)

logger.info(f"Fetched branch: {branch_name} for repo: {repo_name}")
logger.info(
f"Fetched branch: {branch_name} for repo: {repo_name} from {organization}"
)

return enrich_with_repository(response, repo_name)

Expand All @@ -39,19 +44,22 @@ async def get_paginated_resources[
](self, options: ExporterOptionsT) -> ASYNC_GENERATOR_RESYNC_TYPE:
"""Get all branches in the repository with pagination."""

repo_name, params = extract_repo_params(dict(options))
repo_name, organization, params = parse_github_options(dict(options))
detailed = bool(params.pop("detailed"))
protection_rules = bool(params.pop("protection_rules"))
repo_name = cast(str, repo_name)

async for branches in self.client.send_paginated_request(
f"{self.client.base_url}/repos/{self.client.organization}/{repo_name}/branches",
f"{self.client.base_url}/repos/{organization}/{repo_name}/branches",
params,
):
logger.info(
f"Fetched batch of {len(branches)} branches from repository {repo_name}"
f"Fetched batch of {len(branches)} branches from repository {repo_name} from {organization}"
)
tasks = [
self._hydrate_branch(repo_name, b, detailed, protection_rules)
self._hydrate_branch(
repo_name, organization, b, detailed, protection_rules
)
for b in branches
]
hydrated = await asyncio.gather(*tasks)
Expand All @@ -63,32 +71,35 @@ async def get_paginated_resources[
async def _hydrate_branch(
self,
repo_name: str,
organization: str,
branch: dict[str, Any],
detailed: bool,
protection_rules: bool,
) -> dict[str, Any]:
branch_name = branch["name"]

if detailed:
branch = await self.fetch_branch(repo_name, branch_name)
branch = await self.fetch_branch(repo_name, branch_name, organization)
logger.debug(
f"Added extra details for branch '{branch_name}' in repo '{repo_name}'."
)

if protection_rules:
branch = await self._enrich_branch_with_protection_rules(repo_name, branch)
branch = await self._enrich_branch_with_protection_rules(
repo_name, branch, organization
)

return enrich_with_repository(branch, repo_name)

async def _enrich_branch_with_protection_rules(
self, repo_name: str, branch: dict[str, Any]
self, repo_name: str, branch: dict[str, Any], organization: str
) -> RAW_ITEM:
"""Return protection rules or None (404/403 ignored by client)."""
branch_name = branch["name"]

endpoint = (
f"{self.client.base_url}/repos/"
f"{self.client.organization}/{repo_name}/branches/{branch_name}/protection"
f"{organization}/{repo_name}/branches/{branch_name}/protection"
)

protection_rules = await self.client.send_api_request(endpoint)
Expand Down
Loading
Loading