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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build the manager binary
FROM --platform=$BUILDPLATFORM golang:1.25 AS builder
FROM --platform=$BUILDPLATFORM golang:1.26 AS builder
ARG TARGETOS
ARG TARGETARCH

Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ A Kubernetes operator for managing GitHub organizations and repositories as code

- **Declarative GitHub Management**: Define organizations and repositories as Kubernetes resources
- **GitHub App Integration**: Secure authentication using GitHub App credentials
- **Advanced Features**: Manage repository rulesets, webhooks, and organization custom properties
- **Multi-Plan Support**: Works with GitHub `free`, `team`, and `enterprise` plans — plan-gated features are automatically skipped when not available
- **Advanced Features**: Manage repository rulesets, webhooks, organization custom properties, and code security configurations
- **Rate Limit Awareness**: Built-in GitHub API rate limit handling with intelligent backoff
- **Startup Spreading**: Distributes reconciliations over time during pod startup to prevent API thundering herd
- **Webhook Validation**: Comprehensive validation of resource specifications
Expand All @@ -28,7 +29,7 @@ A Kubernetes operator for managing GitHub organizations and repositories as code

### Prerequisites

- A **GitHub Enterprise Cloud** organization — the operator relies on Enterprise-only APIs (organization rulesets, code security configurations, IDP group sync). Repository and team management works on all plans, but full organization reconciliation requires Enterprise Cloud.
- A GitHub organization on any plan (`free`, `team`, or `enterprise`). Set `spec.plan` on the `Organization` resource to match your GitHub plan — defaults to `enterprise` for backward compatibility. Feature availability varies by plan (see [GitHub Plan Compatibility](#github-plan-compatibility) below).
- Go 1.25.5 or later
- Kubernetes cluster (v1.34+ recommended)
- kubectl configured to access your cluster
Expand All @@ -50,6 +51,22 @@ Edit `.env` to configure local settings such as `LOG_LEVEL`, `LOG_FORMAT`, and `

For the full development setup, make targets, testing, and code conventions, see [CONTRIBUTING.md](CONTRIBUTING.md).

## GitHub Plan Compatibility

The operator supports GitHub organizations on all billing plans. Feature availability is automatically gated by the `spec.plan` field on the `Organization` resource:

| Feature | free | team | enterprise |
|---|---|---|---|
| Repository & organization settings | ✓ | ✓ | ✓ |
| Repository rulesets (public repos) | ✓ | ✓ | ✓ |
| Repository rulesets (private/internal repos) | ✗ | ✓ | ✓ |
| Organization rulesets | ✗ | ✓ | ✓ |
| Code security configurations | ✗ | ✗ | ✓ |
| IDP group sync (Teams) | ✗ | ✗ | ✓ |
| Internal repository visibility | ✗ | ✗ | ✓ |

Invalid plan and feature combinations are rejected during resource validation (admission webhook). Plan defaults to `enterprise` for backward compatibility.

## Configuration

### Logging
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions api/v1alpha1/organization_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

const (
// PlanFree represents the GitHub free plan
PlanFree = "free"
// PlanTeam represents the GitHub team plan
PlanTeam = "team"
// PlanEnterprise represents the GitHub enterprise plan
PlanEnterprise = "enterprise"

// VisibilityPublic represents a public repository visible to everyone
VisibilityPublic = "public"
// VisibilityPrivate represents a private repository visible only to explicit collaborators
VisibilityPrivate = "private"
// VisibilityInternal represents an internal repository visible to organization members (Enterprise only)
VisibilityInternal = "internal"
)

func (in *Organization) GetTypeRepresentation() string {
return "Organization"
}
Expand Down Expand Up @@ -43,6 +59,25 @@ func (in *Organization) GetObservedSubResourceGenerations() map[string]int64 {
return in.Status.ObservedSubResourceGenerations
}

func (in *Organization) GetPlan() string {
if in == nil {
return ""
}
if in.Spec.Plan == "" {
return PlanEnterprise
}
return in.Spec.Plan
}

// HasEnterpriseFeatures returns true if the organization has enterprise-level features.
// Returns false for free plan, true for enterprise and other plans.
func (in *Organization) HasEnterpriseFeatures() bool {
if in == nil {
return false
}
return in.GetPlan() != PlanFree
}

func (in *Organization) SetObservedSubResourceGeneration(new map[string]int64) {
if in == nil {
return
Expand Down
7 changes: 7 additions & 0 deletions api/v1alpha1/organization_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,13 @@ type OrganizationSpec struct {
// Description is a human-readable description of the organization.
// This appears on the organization's GitHub profile page.
Description string `json:"description"`

// Plan indicates the GitHub plan tier for this organization (enterprise, team, or free).
// Determines whether Enterprise-only features (e.g., custom properties, runner groups) are reconciled or skipped.
// +kubebuilder:validation:Enum=enterprise;team;free
// +kubebuilder:default=enterprise
// +optional
Plan string `json:"plan,omitempty"`
}

// OrganizationStatus defines the observed state of Organization.
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/github.interhyp.de_organizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,16 @@ spec:
minLength: 1
pattern: ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$
type: string
plan:
default: enterprise
description: |-
Plan indicates the GitHub plan tier for this organization (enterprise, team, or free).
Determines whether Enterprise-only features (e.g., custom properties, runner groups) are reconciled or skipped.
enum:
- enterprise
- team
- free
type: string
rulesetPresets:
description: |-
RulesetPresetList references RulesetPreset CRDs that define repository rulesets for this organization.
Expand Down
1 change: 1 addition & 0 deletions docs/techdocs/crds.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ _Appears in:_
| `codeSecurityConfigurations` _[AttachableCodeSecurityConfigurationRef](#attachablecodesecurityconfigurationref) array_ | CodeSecurityConfigurations lists code security configurations to create and optionally attach to repositories.<br />Each configuration defines security features like dependency scanning, secret scanning, and code scanning.<br />See: https://docs.github.com/en/rest/code-security/configurations | | |
| `rulesetPresets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#localobjectreference-v1-core) array_ | RulesetPresetList references RulesetPreset CRDs that define repository rulesets for this organization.<br />Rulesets enforce policies like branch protection, required reviews, and status checks.<br />See: https://docs.github.com/en/rest/orgs/rules | | |
| `description` _string_ | Description is a human-readable description of the organization.<br />This appears on the organization's GitHub profile page. | | |
| `plan` _string_ | Plan indicates the GitHub plan tier for this organization (enterprise, team, or free).<br />Determines whether Enterprise-only features (e.g., custom properties, runner groups) are reconciled or skipped. | enterprise | Enum: [enterprise team free] <br />Optional: \{\} <br /> |


#### OrganizationStatus
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/Interhyp/git-hubby

go 1.25.7
go 1.26.3

require (
github.com/PuerkitoBio/rehttp v1.4.0
Expand Down
3 changes: 1 addition & 2 deletions internal/controller/organization_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import (
"github.com/Interhyp/git-hubby/internal/reconciler/reconcilerfactory"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/workqueue"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
Expand Down Expand Up @@ -125,7 +124,7 @@ func (r *OrganizationCtl) SetupWithManager(mgr ctrl.Manager) error {
).
WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicate.AnnotationChangedPredicate{})).
WithOptions(controller.Options{
UsePriorityQueue: ptr.To[bool](true),
UsePriorityQueue: new(true),
RateLimiter: workqueue.NewTypedMaxOfRateLimiter(
workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](
1*time.Second, // base delay
Expand Down
8 changes: 4 additions & 4 deletions internal/controller/organization_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ var _ = Describe("Organization Controller - Integration Tests", func() {
return &github.Organization{
Login: github.Ptr(orgName),
Name: github.Ptr(orgName),
Description: github.Ptr("Test organization for unit tests"),
Description: new("Test organization for unit tests"),
}, nil
}
mockClient.GetAllOrganizationCustomPropertiesFunc = func(ctx context.Context, org string) ([]*github.CustomProperty, error) {
Expand Down Expand Up @@ -108,7 +108,7 @@ var _ = Describe("Organization Controller - Integration Tests", func() {
return &github.Organization{
Login: github.Ptr(orgName),
Name: github.Ptr(orgName),
Description: github.Ptr("Test organization for unit tests"),
Description: new("Test organization for unit tests"),
}, nil
}
mockClient.GetAllOrganizationCustomPropertiesFunc = func(ctx context.Context, org string) ([]*github.CustomProperty, error) {
Expand Down Expand Up @@ -148,7 +148,7 @@ var _ = Describe("Organization Controller - Integration Tests", func() {
return &github.Organization{
Login: github.Ptr(orgName),
Name: github.Ptr(orgName),
Description: github.Ptr("Test organization for unit tests"),
Description: new("Test organization for unit tests"),
}, nil
}
mockClient.GetAllOrganizationCustomPropertiesFunc = func(ctx context.Context, org string) ([]*github.CustomProperty, error) {
Expand Down Expand Up @@ -194,7 +194,7 @@ var _ = Describe("Organization Controller - Integration Tests", func() {
return &github.Organization{
Login: github.Ptr(orgName),
Name: github.Ptr(orgName),
Description: github.Ptr("Test organization"),
Description: new("Test organization"),
}, nil
}
mockClient.GetAllOrganizationCustomPropertiesFunc = func(ctx context.Context, org string) ([]*github.CustomProperty, error) {
Expand Down
3 changes: 1 addition & 2 deletions internal/controller/repository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"github.com/google/go-github/v86/github"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/workqueue"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/predicate"
Expand Down Expand Up @@ -189,7 +188,7 @@ func (r *RepositoryCtl) SetupWithManager(mgr ctrl.Manager) error {
).
WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicate.AnnotationChangedPredicate{})).
WithOptions(controller.Options{
UsePriorityQueue: ptr.To[bool](true),
UsePriorityQueue: new(true),
MaxConcurrentReconciles: 20,
RateLimiter: workqueue.NewTypedMaxOfRateLimiter(
workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](
Expand Down
52 changes: 26 additions & 26 deletions internal/controller/repository_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ var _ = Describe("Repository Controller - Integration Tests", func() {
testEnv.CreateTestNamespace(namespaceName)
testEnv.CreateSecret(namespaceName, secretName)
organization = testEnv.SetupOrganizationTest(nil, namespaceName, orgName)
organization.Spec.ActionsSettings.EnabledRepositories = github.Ptr("all")
organization.Spec.ActionsSettings.EnabledRepositories = new("all")
Expect(testEnv.Client.Update(testEnv.Context, organization)).To(Succeed())
})

Expand Down Expand Up @@ -94,12 +94,12 @@ var _ = Describe("Repository Controller - Integration Tests", func() {
By("Setting up mock to return existing repository")
mockClient.GetRepositoryFunc = func(ctx context.Context, owner, repo string) (*github.Repository, error) {
return &github.Repository{
ID: github.Ptr(int64(12345)),
ID: new(int64(12345)),
Name: github.Ptr(repoName),
FullName: github.Ptr(owner + "/" + repo),
Owner: &github.User{Login: github.Ptr(owner)},
Archived: github.Ptr(false),
Visibility: github.Ptr("internal"),
FullName: new(owner + "/" + repo),
Owner: &github.User{Login: new(owner)},
Archived: new(false),
Visibility: new("internal"),
}, nil
}

Expand Down Expand Up @@ -189,27 +189,27 @@ var _ = Describe("Repository Controller - Integration Tests", func() {
By("Setting up mock to return repository with ID")
mockClient.GetRepositoryFunc = func(ctx context.Context, owner, repo string) (*github.Repository, error) {
return &github.Repository{
ID: github.Ptr(int64(99999)),
ID: new(int64(99999)),
Name: github.Ptr(repoName),
FullName: github.Ptr(owner + "/" + repo),
Owner: &github.User{Login: github.Ptr(owner)},
Archived: github.Ptr(false),
Visibility: github.Ptr("internal"),
HasIssues: github.Ptr(true),
HasProjects: github.Ptr(false),
HasWiki: github.Ptr(false),
HasDownloads: github.Ptr(false),
IsTemplate: github.Ptr(false),
AutoInit: github.Ptr(true),
AllowSquashMerge: github.Ptr(false),
AllowRebaseMerge: github.Ptr(false),
AllowMergeCommit: github.Ptr(false),
DeleteBranchOnMerge: github.Ptr(true),
MergeCommitTitle: github.Ptr("MERGE_MESSAGE"),
MergeCommitMessage: github.Ptr("PR_TITLE"),
Homepage: github.Ptr(""),
Description: github.Ptr(""),
DefaultBranch: github.Ptr(""),
FullName: new(owner + "/" + repo),
Owner: &github.User{Login: new(owner)},
Archived: new(false),
Visibility: new("internal"),
HasIssues: new(true),
HasProjects: new(false),
HasWiki: new(false),
HasDownloads: new(false),
IsTemplate: new(false),
AutoInit: new(true),
AllowSquashMerge: new(false),
AllowRebaseMerge: new(false),
AllowMergeCommit: new(false),
DeleteBranchOnMerge: new(true),
MergeCommitTitle: new("MERGE_MESSAGE"),
MergeCommitMessage: new("PR_TITLE"),
Homepage: new(""),
Description: new(""),
DefaultBranch: new(""),
}, nil
}

Expand Down
3 changes: 1 addition & 2 deletions internal/controller/team_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import (
"github.com/Interhyp/git-hubby/internal/reconciler/reconcilerfactory"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/util/workqueue"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
Expand Down Expand Up @@ -99,7 +98,7 @@ func (r *TeamCtl) SetupWithManager(mgr ctrl.Manager) error {
For(&githubv1alpha1.Team{}).
WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicate.AnnotationChangedPredicate{})).
WithOptions(controller.Options{
UsePriorityQueue: ptr.To[bool](true),
UsePriorityQueue: new(true),
MaxConcurrentReconciles: 20,
RateLimiter: workqueue.NewTypedMaxOfRateLimiter(
workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](
Expand Down
22 changes: 11 additions & 11 deletions internal/controller/team_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,10 @@ var _ = Describe("TeamController", func() {
return &github.Team{
Name: github.Ptr(teamName),
Slug: github.Ptr(teamName),
Description: github.Ptr(""),
Privacy: github.Ptr("closed"),
Permission: github.Ptr("pull"),
NotificationSetting: github.Ptr("notifications_disabled"),
Description: new(""),
Privacy: new("closed"),
Permission: new("pull"),
NotificationSetting: new("notifications_disabled"),
}, nil
}

Expand All @@ -181,21 +181,21 @@ var _ = Describe("TeamController", func() {
return &github.Team{
Name: github.Ptr(teamName),
Slug: github.Ptr(teamName),
Description: github.Ptr(""),
Privacy: github.Ptr("closed"),
Permission: github.Ptr("pull"),
NotificationSetting: github.Ptr("notifications_disabled"),
Description: new(""),
Privacy: new("closed"),
Permission: new("pull"),
NotificationSetting: new("notifications_disabled"),
}, nil
}
mockClient.ListMembersFunc = func(ctx context.Context, org string) ([]*github.User, error) {
return []*github.User{
{Login: github.Ptr("new-member_memberSuffix")},
{Login: github.Ptr("existing-member_memberSuffix")},
{Login: new("new-member_memberSuffix")},
{Login: new("existing-member_memberSuffix")},
}, nil
}
mockClient.GetAllTeamMembersFunc = func(ctx context.Context, org string, slug string) ([]*github.User, error) {
return []*github.User{
{Login: github.Ptr("existing-member_memberSuffix")},
{Login: new("existing-member_memberSuffix")},
}, nil
}

Expand Down
Loading
Loading