diff --git a/api/v1alpha1/applyconfiguration/api/v1alpha1/repositorycollaboratorpermission.go b/api/v1alpha1/applyconfiguration/api/v1alpha1/repositorycollaboratorpermission.go
new file mode 100644
index 0000000..fce6568
--- /dev/null
+++ b/api/v1alpha1/applyconfiguration/api/v1alpha1/repositorycollaboratorpermission.go
@@ -0,0 +1,58 @@
+/*
+Copyright 2025.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1alpha1
+
+// RepositoryCollaboratorPermissionApplyConfiguration represents a declarative configuration of the RepositoryCollaboratorPermission type for use
+// with apply.
+//
+// RepositoryCollaboratorPermission defines repository-level access for a GitHub user.
+type RepositoryCollaboratorPermissionApplyConfiguration struct {
+ // Username is the GitHub username of the member.
+ // Supports standard GitHub usernames and Enterprise Managed Users-style
+ // usernames with an underscore suffix (for example: "name-surname_org").
+ Username *string `json:"username,omitempty"`
+ // Permission is the access level granted to the member for this repository.
+ // - "pull": Read-only access
+ // - "triage": Manage issues and pull requests without write access
+ // - "push": Read and write access
+ // - "maintain": Manage repository settings except sensitive/destructive actions
+ // - "admin": Full repository administrative access
+ Permission *string `json:"permission,omitempty"`
+}
+
+// RepositoryCollaboratorPermissionApplyConfiguration constructs a declarative configuration of the RepositoryCollaboratorPermission type for use with
+// apply.
+func RepositoryCollaboratorPermission() *RepositoryCollaboratorPermissionApplyConfiguration {
+ return &RepositoryCollaboratorPermissionApplyConfiguration{}
+}
+
+// WithUsername sets the Username field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Username field is set to the value of the last call.
+func (b *RepositoryCollaboratorPermissionApplyConfiguration) WithUsername(value string) *RepositoryCollaboratorPermissionApplyConfiguration {
+ b.Username = &value
+ return b
+}
+
+// WithPermission sets the Permission field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Permission field is set to the value of the last call.
+func (b *RepositoryCollaboratorPermissionApplyConfiguration) WithPermission(value string) *RepositoryCollaboratorPermissionApplyConfiguration {
+ b.Permission = &value
+ return b
+}
diff --git a/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryspec.go b/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryspec.go
index 693a683..e011085 100644
--- a/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryspec.go
+++ b/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryspec.go
@@ -104,6 +104,16 @@ type RepositorySpecApplyConfiguration struct {
AvailableActionsRunnerGroups []string `json:"availableActionsRunnerGroups,omitempty"`
// OrganizationRef references the Organization CRD this repository belongs to.
OrganizationRef *OrganizationRefApplyConfiguration `json:"organizationRef,omitempty"`
+ // Teams defines team permissions for this repository.
+ // Each entry references an existing Team CRD via TeamRef and assigns it a specific permission level.
+ // Teams not present in this list will have their explicit repository access removed.
+ // See: https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions
+ Teams []RepositoryTeamPermissionApplyConfiguration `json:"teams,omitempty"`
+ // Collaborators defines direct repository member (collaborator) permissions.
+ // Each entry assigns a GitHub user a specific permission level.
+ // Collaborators not present in this list will have their explicit repository access removed.
+ // See: https://docs.github.com/en/rest/collaborators/collaborators#add-a-repository-collaborator
+ Collaborators []RepositoryCollaboratorPermissionApplyConfiguration `json:"collaborators,omitempty"`
// RulesetPresetList references RulesetPreset CRDs to apply to this repository.
// These define branch protection rules, required status checks, and other policies.
// See: https://docs.github.com/en/rest/repos/rules
@@ -299,6 +309,32 @@ func (b *RepositorySpecApplyConfiguration) WithOrganizationRef(value *Organizati
return b
}
+// WithTeams adds the given value to the Teams field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the Teams field.
+func (b *RepositorySpecApplyConfiguration) WithTeams(values ...*RepositoryTeamPermissionApplyConfiguration) *RepositorySpecApplyConfiguration {
+ for i := range values {
+ if values[i] == nil {
+ panic("nil value passed to WithTeams")
+ }
+ b.Teams = append(b.Teams, *values[i])
+ }
+ return b
+}
+
+// WithCollaborators adds the given value to the Collaborators field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the Collaborators field.
+func (b *RepositorySpecApplyConfiguration) WithCollaborators(values ...*RepositoryCollaboratorPermissionApplyConfiguration) *RepositorySpecApplyConfiguration {
+ for i := range values {
+ if values[i] == nil {
+ panic("nil value passed to WithCollaborators")
+ }
+ b.Collaborators = append(b.Collaborators, *values[i])
+ }
+ return b
+}
+
// WithRulesetPresetList adds the given value to the RulesetPresetList field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the RulesetPresetList field.
diff --git a/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryteampermission.go b/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryteampermission.go
new file mode 100644
index 0000000..29af06b
--- /dev/null
+++ b/api/v1alpha1/applyconfiguration/api/v1alpha1/repositoryteampermission.go
@@ -0,0 +1,56 @@
+/*
+Copyright 2025.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1alpha1
+
+// RepositoryTeamPermissionApplyConfiguration represents a declarative configuration of the RepositoryTeamPermission type for use
+// with apply.
+//
+// RepositoryTeamPermission defines repository-level access for a GitHub team.
+type RepositoryTeamPermissionApplyConfiguration struct {
+ // TeamRef references the Team CRD that should be granted access to this repository.
+ TeamRef *TeamRefApplyConfiguration `json:"teamRef,omitempty"`
+ // Permission is the access level granted to the team for this repository.
+ // - "pull": Read-only access
+ // - "triage": Manage issues and pull requests without write access
+ // - "push": Read and write access
+ // - "maintain": Manage repository settings except sensitive/destructive actions
+ // - "admin": Full repository administrative access
+ Permission *string `json:"permission,omitempty"`
+}
+
+// RepositoryTeamPermissionApplyConfiguration constructs a declarative configuration of the RepositoryTeamPermission type for use with
+// apply.
+func RepositoryTeamPermission() *RepositoryTeamPermissionApplyConfiguration {
+ return &RepositoryTeamPermissionApplyConfiguration{}
+}
+
+// WithTeamRef sets the TeamRef field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the TeamRef field is set to the value of the last call.
+func (b *RepositoryTeamPermissionApplyConfiguration) WithTeamRef(value *TeamRefApplyConfiguration) *RepositoryTeamPermissionApplyConfiguration {
+ b.TeamRef = value
+ return b
+}
+
+// WithPermission sets the Permission field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Permission field is set to the value of the last call.
+func (b *RepositoryTeamPermissionApplyConfiguration) WithPermission(value string) *RepositoryTeamPermissionApplyConfiguration {
+ b.Permission = &value
+ return b
+}
diff --git a/api/v1alpha1/applyconfiguration/api/v1alpha1/teamref.go b/api/v1alpha1/applyconfiguration/api/v1alpha1/teamref.go
new file mode 100644
index 0000000..18b57d4
--- /dev/null
+++ b/api/v1alpha1/applyconfiguration/api/v1alpha1/teamref.go
@@ -0,0 +1,43 @@
+/*
+Copyright 2025.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1alpha1
+
+// TeamRefApplyConfiguration represents a declarative configuration of the TeamRef type for use
+// with apply.
+//
+// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
+// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
+// TeamRef is a reference to a Team CRD.
+type TeamRefApplyConfiguration struct {
+ // Name is the name of the referenced Team CRD.
+ Name *string `json:"name,omitempty"`
+}
+
+// TeamRefApplyConfiguration constructs a declarative configuration of the TeamRef type for use with
+// apply.
+func TeamRef() *TeamRefApplyConfiguration {
+ return &TeamRefApplyConfiguration{}
+}
+
+// WithName sets the Name field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Name field is set to the value of the last call.
+func (b *TeamRefApplyConfiguration) WithName(value string) *TeamRefApplyConfiguration {
+ b.Name = &value
+ return b
+}
diff --git a/api/v1alpha1/applyconfiguration/api/v1alpha1/teamspec.go b/api/v1alpha1/applyconfiguration/api/v1alpha1/teamspec.go
index 2b3bc37..095ff04 100644
--- a/api/v1alpha1/applyconfiguration/api/v1alpha1/teamspec.go
+++ b/api/v1alpha1/applyconfiguration/api/v1alpha1/teamspec.go
@@ -20,8 +20,6 @@ package v1alpha1
// TeamSpecApplyConfiguration represents a declarative configuration of the TeamSpec type for use
// with apply.
//
-// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
-// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// TeamSpec defines the desired state of Team within one or more Organizations.
// Teams group organization members and can be assigned permissions to repositories.
// A Team can exist in multiple organizations simultaneously.
diff --git a/api/v1alpha1/applyconfiguration/internal/internal.go b/api/v1alpha1/applyconfiguration/internal/internal.go
index 29104fe..257c7fc 100644
--- a/api/v1alpha1/applyconfiguration/internal/internal.go
+++ b/api/v1alpha1/applyconfiguration/internal/internal.go
@@ -574,6 +574,16 @@ var schemaYAML = typed.YAMLObject(`types:
- name: status
type:
namedType: com.github.Interhyp.git-hubby.api.v1alpha1.RepositoryStatus
+- name: com.github.Interhyp.git-hubby.api.v1alpha1.RepositoryCollaboratorPermission
+ map:
+ fields:
+ - name: permission
+ type:
+ scalar: string
+ default: pull
+ - name: username
+ type:
+ scalar: string
- name: com.github.Interhyp.git-hubby.api.v1alpha1.RepositoryNameCondition
map:
fields:
@@ -665,6 +675,14 @@ var schemaYAML = typed.YAMLObject(`types:
elementType:
scalar: string
elementRelationship: atomic
+ - name: collaborators
+ type:
+ list:
+ elementType:
+ namedType: com.github.Interhyp.git-hubby.api.v1alpha1.RepositoryCollaboratorPermission
+ elementRelationship: associative
+ keys:
+ - username
- name: customProperties
type:
list:
@@ -725,6 +743,12 @@ var schemaYAML = typed.YAMLObject(`types:
elementType:
namedType: io.k8s.api.core.v1.LocalObjectReference
elementRelationship: atomic
+ - name: teams
+ type:
+ list:
+ elementType:
+ namedType: com.github.Interhyp.git-hubby.api.v1alpha1.RepositoryTeamPermission
+ elementRelationship: atomic
- name: visibility
type:
scalar: string
@@ -765,6 +789,16 @@ var schemaYAML = typed.YAMLObject(`types:
map:
elementType:
namedType: com.github.Interhyp.git-hubby.api.v1alpha1.WebhookStatus
+- name: com.github.Interhyp.git-hubby.api.v1alpha1.RepositoryTeamPermission
+ map:
+ fields:
+ - name: permission
+ type:
+ scalar: string
+ default: pull
+ - name: teamRef
+ type:
+ namedType: com.github.Interhyp.git-hubby.api.v1alpha1.TeamRef
- name: com.github.Interhyp.git-hubby.api.v1alpha1.RequiredPullRequestReviewer
map:
fields:
@@ -1022,6 +1056,12 @@ var schemaYAML = typed.YAMLObject(`types:
- name: status
type:
namedType: com.github.Interhyp.git-hubby.api.v1alpha1.TeamStatus
+- name: com.github.Interhyp.git-hubby.api.v1alpha1.TeamRef
+ map:
+ fields:
+ - name: name
+ type:
+ scalar: string
- name: com.github.Interhyp.git-hubby.api.v1alpha1.TeamSpec
map:
fields:
diff --git a/api/v1alpha1/applyconfiguration/utils.go b/api/v1alpha1/applyconfiguration/utils.go
index 4659c69..8171e90 100644
--- a/api/v1alpha1/applyconfiguration/utils.go
+++ b/api/v1alpha1/applyconfiguration/utils.go
@@ -93,6 +93,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1alpha1.RefNameConditionApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("Repository"):
return &apiv1alpha1.RepositoryApplyConfiguration{}
+ case v1alpha1.SchemeGroupVersion.WithKind("RepositoryCollaboratorPermission"):
+ return &apiv1alpha1.RepositoryCollaboratorPermissionApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("RepositoryNameCondition"):
return &apiv1alpha1.RepositoryNameConditionApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("RepositoryPropertyCondition"):
@@ -103,6 +105,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1alpha1.RepositorySpecApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("RepositoryStatus"):
return &apiv1alpha1.RepositoryStatusApplyConfiguration{}
+ case v1alpha1.SchemeGroupVersion.WithKind("RepositoryTeamPermission"):
+ return &apiv1alpha1.RepositoryTeamPermissionApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("RequiredPullRequestReviewer"):
return &apiv1alpha1.RequiredPullRequestReviewerApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("RequiredStatusChecks"):
@@ -131,6 +135,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1alpha1.StatusCheckApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("Team"):
return &apiv1alpha1.TeamApplyConfiguration{}
+ case v1alpha1.SchemeGroupVersion.WithKind("TeamRef"):
+ return &apiv1alpha1.TeamRefApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("TeamSpec"):
return &apiv1alpha1.TeamSpecApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("TeamStatus"):
diff --git a/api/v1alpha1/repository_types.go b/api/v1alpha1/repository_types.go
index 8393449..50bd05d 100644
--- a/api/v1alpha1/repository_types.go
+++ b/api/v1alpha1/repository_types.go
@@ -179,6 +179,20 @@ type RepositorySpec struct {
// +kubebuilder:validation:Required
OrganizationRef OrganizationRef `json:"organizationRef,omitempty"`
+ // Teams defines team permissions for this repository.
+ // Each entry references an existing Team CRD via TeamRef and assigns it a specific permission level.
+ // Teams not present in this list will have their explicit repository access removed.
+ // See: https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions
+ Teams []RepositoryTeamPermission `json:"teams,omitempty"`
+
+ // Collaborators defines direct repository member (collaborator) permissions.
+ // Each entry assigns a GitHub user a specific permission level.
+ // Collaborators not present in this list will have their explicit repository access removed.
+ // See: https://docs.github.com/en/rest/collaborators/collaborators#add-a-repository-collaborator
+ // +listType=map
+ // +listMapKey=username
+ Collaborators []RepositoryCollaboratorPermission `json:"collaborators,omitempty"`
+
// RulesetPresetList references RulesetPreset CRDs to apply to this repository.
// These define branch protection rules, required status checks, and other policies.
// See: https://docs.github.com/en/rest/repos/rules
@@ -217,6 +231,45 @@ type CodeSecurityConfigurationRef struct {
Name string `json:"name"`
}
+// RepositoryTeamPermission defines repository-level access for a GitHub team.
+type RepositoryTeamPermission struct {
+ // TeamRef references the Team CRD that should be granted access to this repository.
+ // +kubebuilder:validation:Required
+ TeamRef TeamRef `json:"teamRef"`
+
+ // Permission is the access level granted to the team for this repository.
+ // - "pull": Read-only access
+ // - "triage": Manage issues and pull requests without write access
+ // - "push": Read and write access
+ // - "maintain": Manage repository settings except sensitive/destructive actions
+ // - "admin": Full repository administrative access
+ // +kubebuilder:validation:Enum=pull;triage;push;maintain;admin
+ // +kubebuilder:default=pull
+ Permission string `json:"permission,omitempty"`
+}
+
+// RepositoryCollaboratorPermission defines repository-level access for a GitHub user.
+type RepositoryCollaboratorPermission struct {
+ // Username is the GitHub username of the member.
+ // Supports standard GitHub usernames and Enterprise Managed Users-style
+ // usernames with an underscore suffix (for example: "name-surname_org").
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=39
+ // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*(?:_[a-zA-Z0-9](?:-?[a-zA-Z0-9])*)?$`
+ // +kubebuilder:validation:Required
+ Username string `json:"username"`
+
+ // Permission is the access level granted to the member for this repository.
+ // - "pull": Read-only access
+ // - "triage": Manage issues and pull requests without write access
+ // - "push": Read and write access
+ // - "maintain": Manage repository settings except sensitive/destructive actions
+ // - "admin": Full repository administrative access
+ // +kubebuilder:validation:Enum=pull;triage;push;maintain;admin
+ // +kubebuilder:default=pull
+ Permission string `json:"permission,omitempty"`
+}
+
// MergeStrategy defines an allowed merge strategy for pull requests.
// See: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/about-merge-methods-on-github
type MergeStrategy struct {
diff --git a/api/v1alpha1/team_types.go b/api/v1alpha1/team_types.go
index 6b52a0c..71e2f1e 100644
--- a/api/v1alpha1/team_types.go
+++ b/api/v1alpha1/team_types.go
@@ -8,6 +8,13 @@ import (
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
+// TeamRef is a reference to a Team CRD.
+type TeamRef struct {
+ // Name is the name of the referenced Team CRD.
+ // +kubebuilder:validation:Required
+ Name string `json:"name"`
+}
+
// TeamSpec defines the desired state of Team within one or more Organizations.
// Teams group organization members and can be assigned permissions to repositories.
// A Team can exist in multiple organizations simultaneously.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 5fd97b2..5672261 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -1018,6 +1018,21 @@ func (in *Repository) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RepositoryCollaboratorPermission) DeepCopyInto(out *RepositoryCollaboratorPermission) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryCollaboratorPermission.
+func (in *RepositoryCollaboratorPermission) DeepCopy() *RepositoryCollaboratorPermission {
+ if in == nil {
+ return nil
+ }
+ out := new(RepositoryCollaboratorPermission)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RepositoryList) DeepCopyInto(out *RepositoryList) {
*out = *in
@@ -1201,6 +1216,16 @@ func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) {
copy(*out, *in)
}
out.OrganizationRef = in.OrganizationRef
+ if in.Teams != nil {
+ in, out := &in.Teams, &out.Teams
+ *out = make([]RepositoryTeamPermission, len(*in))
+ copy(*out, *in)
+ }
+ if in.Collaborators != nil {
+ in, out := &in.Collaborators, &out.Collaborators
+ *out = make([]RepositoryCollaboratorPermission, len(*in))
+ copy(*out, *in)
+ }
if in.RulesetPresetList != nil {
in, out := &in.RulesetPresetList, &out.RulesetPresetList
*out = make([]corev1.LocalObjectReference, len(*in))
@@ -1286,6 +1311,22 @@ func (in *RepositoryStatus) DeepCopy() *RepositoryStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *RepositoryTeamPermission) DeepCopyInto(out *RepositoryTeamPermission) {
+ *out = *in
+ out.TeamRef = in.TeamRef
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryTeamPermission.
+func (in *RepositoryTeamPermission) DeepCopy() *RepositoryTeamPermission {
+ if in == nil {
+ return nil
+ }
+ out := new(RepositoryTeamPermission)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RequiredPullRequestReviewer) DeepCopyInto(out *RequiredPullRequestReviewer) {
*out = *in
@@ -1783,6 +1824,21 @@ func (in *TeamList) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *TeamRef) DeepCopyInto(out *TeamRef) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeamRef.
+func (in *TeamRef) DeepCopy() *TeamRef {
+ if in == nil {
+ return nil
+ }
+ out := new(TeamRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TeamSpec) DeepCopyInto(out *TeamSpec) {
*out = *in
diff --git a/config/crd/bases/github.interhyp.de_repositories.yaml b/config/crd/bases/github.interhyp.de_repositories.yaml
index 819aaa7..20a6da1 100644
--- a/config/crd/bases/github.interhyp.de_repositories.yaml
+++ b/config/crd/bases/github.interhyp.de_repositories.yaml
@@ -172,6 +172,48 @@ spec:
items:
type: string
type: array
+ collaborators:
+ description: |-
+ Collaborators defines direct repository member (collaborator) permissions.
+ Each entry assigns a GitHub user a specific permission level.
+ Collaborators not present in this list will have their explicit repository access removed.
+ See: https://docs.github.com/en/rest/collaborators/collaborators#add-a-repository-collaborator
+ items:
+ description: RepositoryCollaboratorPermission defines repository-level
+ access for a GitHub user.
+ properties:
+ permission:
+ default: pull
+ description: |-
+ Permission is the access level granted to the member for this repository.
+ - "pull": Read-only access
+ - "triage": Manage issues and pull requests without write access
+ - "push": Read and write access
+ - "maintain": Manage repository settings except sensitive/destructive actions
+ - "admin": Full repository administrative access
+ enum:
+ - pull
+ - triage
+ - push
+ - maintain
+ - admin
+ type: string
+ username:
+ description: |-
+ Username is the GitHub username of the member.
+ Supports standard GitHub usernames and Enterprise Managed Users-style
+ usernames with an underscore suffix (for example: "name-surname_org").
+ maxLength: 39
+ minLength: 1
+ pattern: ^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*(?:_[a-zA-Z0-9](?:-?[a-zA-Z0-9])*)?$
+ type: string
+ required:
+ - username
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - username
+ x-kubernetes-list-type: map
customProperties:
description: |-
CustomProperties is a list of custom property values to apply to this repository.
@@ -356,6 +398,46 @@ spec:
type: object
x-kubernetes-map-type: atomic
type: array
+ teams:
+ description: |-
+ Teams defines team permissions for this repository.
+ Each entry references an existing Team CRD via TeamRef and assigns it a specific permission level.
+ Teams not present in this list will have their explicit repository access removed.
+ See: https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions
+ items:
+ description: RepositoryTeamPermission defines repository-level access
+ for a GitHub team.
+ properties:
+ permission:
+ default: pull
+ description: |-
+ Permission is the access level granted to the team for this repository.
+ - "pull": Read-only access
+ - "triage": Manage issues and pull requests without write access
+ - "push": Read and write access
+ - "maintain": Manage repository settings except sensitive/destructive actions
+ - "admin": Full repository administrative access
+ enum:
+ - pull
+ - triage
+ - push
+ - maintain
+ - admin
+ type: string
+ teamRef:
+ description: TeamRef references the Team CRD that should be
+ granted access to this repository.
+ properties:
+ name:
+ description: Name is the name of the referenced Team CRD.
+ type: string
+ required:
+ - name
+ type: object
+ required:
+ - teamRef
+ type: object
+ type: array
visibility:
default: private
description: |-
diff --git a/docs/configuration/repository.md b/docs/configuration/repository.md
index 4344761..9fea4a4 100644
--- a/docs/configuration/repository.md
+++ b/docs/configuration/repository.md
@@ -63,11 +63,23 @@ spec:
# --- Team Permissions ---
teams:
- - name: checkout-team
+ - teamRef:
+ name: checkout-team
permission: push
- - name: platform-team
+ - teamRef:
+ name: platform-team
permission: admin
- - name: security-team
+ - teamRef:
+ name: security-team
+ permission: triage
+
+ # --- Collaborator Permissions ---
+ collaborators:
+ - username: alice
+ permission: push
+ - username: bob
+ permission: admin
+ - username: charlie
permission: triage
# --- Webhooks ---
diff --git a/docs/crds.md b/docs/crds.md
index 9ddf445..1d1e5cf 100644
--- a/docs/crds.md
+++ b/docs/crds.md
@@ -733,6 +733,23 @@ _Appears in:_
| `status` _[RepositoryStatus](#repositorystatus)_ | status defines the observed state of Repository | | Optional: \{\}
|
+#### RepositoryCollaboratorPermission
+
+
+
+RepositoryCollaboratorPermission defines repository-level access for a GitHub user.
+
+
+
+_Appears in:_
+- [RepositorySpec](#repositoryspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `username` _string_ | Username is the GitHub username of the member.
Supports standard GitHub usernames and Enterprise Managed Users-style
usernames with an underscore suffix (for example: "name-surname_org"). | | MaxLength: 39
MinLength: 1
Pattern: `^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*(?:_[a-zA-Z0-9](?:-?[a-zA-Z0-9])*)?$`
Required: \{\}
|
+| `permission` _string_ | Permission is the access level granted to the member for this repository.
- "pull": Read-only access
- "triage": Manage issues and pull requests without write access
- "push": Read and write access
- "maintain": Manage repository settings except sensitive/destructive actions
- "admin": Full repository administrative access | pull | Enum: [pull triage push maintain admin]
|
+
+
#### RepositoryList
@@ -848,6 +865,8 @@ _Appears in:_
| `accessLevelForExternalWorkflows` _string_ | AccessLevelForExternalWorkflows controls access to workflows outside the repository.
- "none": Only workflows in this repository can access actions and reusable workflows
- "user": Workflows in user-owned private repositories can access them
- "organization": Workflows across the organization can access them
- "enterprise": Workflows across the enterprise can access them
See: https://docs.github.com/en/rest/actions/permissions | none | Enum: [none user organization enterprise]
|
| `availableActionsRunnerGroups` _string array_ | AvailableActionsRunnerGroups lists runner group names that this repository can use.
This is only relevant when the organization's runner groups have "selected" visibility.
See: https://docs.github.com/en/rest/actions/self-hosted-runner-groups | | |
| `organizationRef` _[OrganizationRef](#organizationref)_ | OrganizationRef references the Organization CRD this repository belongs to. | | Required: \{\}
|
+| `teams` _[RepositoryTeamPermission](#repositoryteampermission) array_ | Teams defines team permissions for this repository.
Each entry references an existing Team CRD via TeamRef and assigns it a specific permission level.
Teams not present in this list will have their explicit repository access removed.
See: https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions | | |
+| `collaborators` _[RepositoryCollaboratorPermission](#repositorycollaboratorpermission) array_ | Collaborators defines direct repository member (collaborator) permissions.
Each entry assigns a GitHub user a specific permission level.
Collaborators not present in this list will have their explicit repository access removed.
See: https://docs.github.com/en/rest/collaborators/collaborators#add-a-repository-collaborator | | |
| `rulesetPresets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#localobjectreference-v1-core) array_ | RulesetPresetList references RulesetPreset CRDs to apply to this repository.
These define branch protection rules, required status checks, and other policies.
See: https://docs.github.com/en/rest/repos/rules | | |
| `webhookPresets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#localobjectreference-v1-core) array_ | WebhookPresetList references WebhookPreset CRDs to create webhooks for this repository.
Webhooks send HTTP POST payloads to external services when specific events occur.
See: https://docs.github.com/en/rest/webhooks/repos | | |
| `webhookIgnorePresets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#localobjectreference-v1-core) array_ | WebhookIgnorePresetsList references WebhookIgnorePreset CRDs that define webhooks to ignore.
Webhooks matching these patterns will not be created even if they are in WebhookPresetList. | | |
@@ -875,6 +894,23 @@ _Appears in:_
| `observedSubResourceGenerations` _object (keys:string, values:integer)_ | ObservedSubResourceGenerations is a map of sub-resource names to their observed generations.
Keys are in the format "/".
SubResources are kubernetes resources that are referenced by this Repository and are not managed
by their own controllers like WebhookPresets, RuleSetPresets and the attached CodeSecurityConfiguration | | |
+#### RepositoryTeamPermission
+
+
+
+RepositoryTeamPermission defines repository-level access for a GitHub team.
+
+
+
+_Appears in:_
+- [RepositorySpec](#repositoryspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `teamRef` _[TeamRef](#teamref)_ | TeamRef references the Team CRD that should be granted access to this repository. | | Required: \{\}
|
+| `permission` _string_ | Permission is the access level granted to the team for this repository.
- "pull": Read-only access
- "triage": Manage issues and pull requests without write access
- "push": Read and write access
- "maintain": Manage repository settings except sensitive/destructive actions
- "admin": Full repository administrative access | pull | Enum: [pull triage push maintain admin]
|
+
+
#### RequiredPullRequestReviewer
@@ -1233,6 +1269,22 @@ TeamList contains a list of Teams
| `items` _[Team](#team) array_ | | | |
+#### TeamRef
+
+
+
+TeamRef is a reference to a Team CRD.
+
+
+
+_Appears in:_
+- [RepositoryTeamPermission](#repositoryteampermission)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name is the name of the referenced Team CRD. | | Required: \{\}
|
+
+
#### TeamSpec
diff --git a/internal/conditions/conditions.go b/internal/conditions/conditions.go
index 4485763..6b392ae 100644
--- a/internal/conditions/conditions.go
+++ b/internal/conditions/conditions.go
@@ -29,6 +29,10 @@ const (
const (
// WebhooksSynced indicates that repository webhooks are configured successfully
TypeWebhooksSynced ConditionType = "WebhooksSynced"
+ // TeamsSynced indicates that repository team permissions are configured successfully
+ TypeTeamsSynced ConditionType = "TeamsSynced"
+ // CollaboratorsSynced indicates that repository collaborator permissions are configured successfully
+ TypeCollaboratorsSynced ConditionType = "CollaboratorsSynced"
// CustomPropertiesValuesSynced indicates that repository custom properties are configured successfully
TypeCustomPropertiesValuesSynced ConditionType = "CustomPropertiesValuesSynced"
// TopicsSynced indicates that repository topics are synced successfully
diff --git a/internal/ghclient/interface.go b/internal/ghclient/interface.go
index a6cbc48..27ce68e 100644
--- a/internal/ghclient/interface.go
+++ b/internal/ghclient/interface.go
@@ -85,6 +85,16 @@ type GitHubClient interface {
AddTeamMember(ctx context.Context, org string, slug string, username string) error
RemoveTeamMember(ctx context.Context, org string, slug string, username string) error
+ // Repository teams operations
+ GetAllRepositoryTeams(ctx context.Context, owner, repo string) ([]*github.Team, error)
+ AddRepositoryTeam(ctx context.Context, org, slug, owner, repo, permission string) error
+ RemoveRepositoryTeam(ctx context.Context, org, slug, owner, repo string) error
+
+ // Repository collaborators operations
+ GetAllRepositoryCollaborators(ctx context.Context, owner, repo string) ([]*github.User, error)
+ AddRepositoryCollaborator(ctx context.Context, owner, repo, username, permission string) error
+ RemoveRepositoryCollaborator(ctx context.Context, owner, repo, username string) error
+
// Team external group operations
GetExternalGroupNamesToIDForOrg(ctx context.Context, org string) (map[string]int64, error)
GetExternalGroupsForTeamBySlug(ctx context.Context, org string, slug string) ([]*github.ExternalGroup, error)
diff --git a/internal/ghclient/wrapper.go b/internal/ghclient/wrapper.go
index 2d560f6..3ce4b96 100644
--- a/internal/ghclient/wrapper.go
+++ b/internal/ghclient/wrapper.go
@@ -401,6 +401,46 @@ func (g *GitHubClientWrapper) RemoveTeamMember(ctx context.Context, org string,
return _handleErrorResponse(response, err)
}
+// Repository team permissions operations
+
+func (g *GitHubClientWrapper) GetAllRepositoryTeams(ctx context.Context, owner, repo string) ([]*github.Team, error) {
+ teams, response, err := g.client.Repositories.ListTeams(ctx, owner, repo, nil)
+ defer _closeBody(response)
+ return teams, _handleErrorResponse(response, err)
+}
+
+func (g *GitHubClientWrapper) AddRepositoryTeam(ctx context.Context, org, slug, owner, repo, permission string) error {
+ response, err := g.client.Teams.AddTeamRepoBySlug(ctx, org, slug, owner, repo, &github.TeamAddTeamRepoOptions{Permission: permission})
+ defer _closeBody(response)
+ return _handleErrorResponse(response, err)
+}
+
+func (g *GitHubClientWrapper) RemoveRepositoryTeam(ctx context.Context, org, slug, owner, repo string) error {
+ response, err := g.client.Teams.RemoveTeamRepoBySlug(ctx, org, slug, owner, repo)
+ defer _closeBody(response)
+ return _handleErrorResponse(response, err)
+}
+
+// Repository collaborators operations
+
+func (g *GitHubClientWrapper) GetAllRepositoryCollaborators(ctx context.Context, owner, repo string) ([]*github.User, error) {
+ users, response, err := g.client.Repositories.ListCollaborators(ctx, owner, repo, &github.ListCollaboratorsOptions{Affiliation: "direct"})
+ defer _closeBody(response)
+ return users, _handleErrorResponse(response, err)
+}
+
+func (g *GitHubClientWrapper) AddRepositoryCollaborator(ctx context.Context, owner, repo, username, permission string) error {
+ _, response, err := g.client.Repositories.AddCollaborator(ctx, owner, repo, username, &github.RepositoryAddCollaboratorOptions{Permission: permission})
+ defer _closeBody(response)
+ return _handleErrorResponse(response, err)
+}
+
+func (g *GitHubClientWrapper) RemoveRepositoryCollaborator(ctx context.Context, owner, repo, username string) error {
+ response, err := g.client.Repositories.RemoveCollaborator(ctx, owner, repo, username)
+ defer _closeBody(response)
+ return _handleErrorResponse(response, err)
+}
+
// Team IDP group operations
func (g *GitHubClientWrapper) GetExternalGroupsForTeamBySlug(ctx context.Context, org string, slug string) ([]*github.ExternalGroup, error) {
result, response, err := g.client.Teams.ListExternalGroupsForTeamBySlug(ctx, org, slug)
diff --git a/internal/reconciler/reconcilerfactory/factory.go b/internal/reconciler/reconcilerfactory/factory.go
index 0ed0db8..946b186 100644
--- a/internal/reconciler/reconcilerfactory/factory.go
+++ b/internal/reconciler/reconcilerfactory/factory.go
@@ -139,6 +139,7 @@ func (f *Factory) CreateForRepo(ctx context.Context, repoName types.NamespacedNa
Name: repo.Spec.Name,
},
},
+ MemberSuffix: f.Config.GitHubMemberSuffix,
FinalizeMode: reconciler.RepositoryFinalizerMode(f.Config.RepositoryFinalizerMode),
Features: f.Config.Features,
},
diff --git a/internal/reconciler/reporec/rec_collaborators.go b/internal/reconciler/reporec/rec_collaborators.go
new file mode 100644
index 0000000..f0ee11f
--- /dev/null
+++ b/internal/reconciler/reporec/rec_collaborators.go
@@ -0,0 +1,105 @@
+package reporec
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/Interhyp/git-hubby/api/v1alpha1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logPkg "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+const defaultRepositoryCollaboratorPermission = "pull"
+
+func (r *GitHubRepoReconciler) reconcileCollaborators(ctx context.Context) error {
+ log := logPkg.FromContext(ctx)
+
+ if r.Kubernetes.Resource.Spec.Collaborators == nil {
+ log.V(1).Info("Collaborators not specified in repository spec, skipping collaborator reconciliation")
+ return nil
+ }
+
+ log.V(1).Info("Reconciling repository collaborators on GitHub")
+
+ memberSuffix := r.MemberSuffix
+ if memberSuffix == "" {
+ var org v1alpha1.Organization
+ if err := r.Kubernetes.Client.Get(ctx, client.ObjectKey{
+ Name: r.Kubernetes.Resource.Spec.OrganizationRef.Name,
+ Namespace: r.Kubernetes.Resource.Namespace,
+ }, &org); err != nil {
+ log.Error(err, "unable to fetch Organization referenced in repository spec", "organization", r.Kubernetes.Resource.Spec.OrganizationRef.Name)
+ return fmt.Errorf("failed to get organization %s: %w", r.Kubernetes.Resource.Spec.OrganizationRef.Name, err)
+ }
+ memberSuffix = org.Spec.MemberSuffix
+ }
+
+ existingCollaborators, err := r.GitHub.Client.GetAllRepositoryCollaborators(ctx, r.GitHub.Resource.Owner, r.GitHub.Resource.Name)
+ if err != nil {
+ log.Error(err, "failed to get existing repository collaborators")
+ return fmt.Errorf("failed to get existing repository collaborators: %w", err)
+ }
+
+ collaboratorsToDelete := make(map[string]string)
+ existingCollaboratorsByName := make(map[string]string)
+ for _, collaborator := range existingCollaborators {
+ if collaborator == nil || collaborator.Login == nil {
+ continue
+ }
+ existingCollaboratorsByName[*collaborator.Login] = collaborator.GetRoleName()
+ collaboratorsToDelete[*collaborator.Login] = *collaborator.Login
+ }
+
+ ghMembers, err := r.GitHub.Client.ListMembers(ctx, r.GitHub.Resource.Owner)
+ if err != nil {
+ log.Error(err, "failed to get members from GitHub")
+ return err
+ }
+ orgMembersByName := make(map[string]string)
+ for _, ghMember := range ghMembers {
+ if ghMember != nil && ghMember.Login != nil {
+ orgMembersByName[*ghMember.Login] = *ghMember.Login
+ }
+ }
+
+ for _, collaboratorRef := range r.Kubernetes.Resource.Spec.Collaborators {
+ collaboratorRef.Username += memberSuffix
+ log := log.WithValues("collaborator", collaboratorRef.Username)
+ log.V(1).Info("Processing collaborator", "permission", collaboratorRef.Permission)
+
+ desiredPermission := repositoryCollaboratorPermission(collaboratorRef)
+ currentPermission, alreadyCollaborator := existingCollaboratorsByName[collaboratorRef.Username]
+
+ if !alreadyCollaborator {
+ if _, found := orgMembersByName[collaboratorRef.Username]; !found {
+ log.Info("WARNING: Collaborator not found as a member of the organization on GitHub", "username", collaboratorRef.Username)
+ continue
+ }
+ }
+
+ if currentPermission != desiredPermission {
+ if err := r.GitHub.Client.AddRepositoryCollaborator(ctx, r.GitHub.Resource.Owner, r.GitHub.Resource.Name, collaboratorRef.Username, desiredPermission); err != nil {
+ log.Error(err, "failed to add or update repository collaborator", "username", collaboratorRef.Username, "permission", desiredPermission)
+ return err
+ }
+ }
+ delete(collaboratorsToDelete, collaboratorRef.Username)
+ }
+
+ for _, username := range collaboratorsToDelete {
+ if err := r.GitHub.Client.RemoveRepositoryCollaborator(ctx, r.GitHub.Resource.Owner, r.GitHub.Resource.Name, username); err != nil {
+ log.Error(err, "failed to remove repository collaborator", "username", username)
+ return err
+ }
+ }
+
+ log.V(1).Info("Successfully reconciled repository collaborators on GitHub")
+ return nil
+}
+
+func repositoryCollaboratorPermission(collaborator v1alpha1.RepositoryCollaboratorPermission) string {
+ if collaborator.Permission == "" {
+ return defaultRepositoryCollaboratorPermission
+ }
+ return collaborator.Permission
+}
diff --git a/internal/reconciler/reporec/rec_collaborators_test.go b/internal/reconciler/reporec/rec_collaborators_test.go
new file mode 100644
index 0000000..91620c6
--- /dev/null
+++ b/internal/reconciler/reporec/rec_collaborators_test.go
@@ -0,0 +1,306 @@
+package reporec
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Interhyp/git-hubby/api/v1alpha1"
+ "github.com/Interhyp/git-hubby/internal/reconciler"
+ "github.com/Interhyp/git-hubby/test/mock/ghclientmock"
+ "github.com/google/go-github/v89/github"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+var _ = Describe("ReconcileCollaborators", func() {
+ var (
+ ctx context.Context
+ mockClient *ghclientmock.MockGitHubClientWrapper
+ k8sClient client.Client
+ rec *GitHubRepoReconciler
+ scheme *runtime.Scheme
+ repo *v1alpha1.Repository
+ org *v1alpha1.Organization
+
+ desiredCollaborators []v1alpha1.RepositoryCollaboratorPermission
+ existingCollaborators []*github.User
+ orgMembers []*github.User
+
+ addCalls []ghclientmock.CollaboratorCall
+ removeCalls []ghclientmock.CollaboratorCall
+
+ listErr error
+ addErr error
+ removeErr error
+
+ err error
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ mockClient = ghclientmock.NewMockGitHubClientWrapper()
+ org = nil
+
+ scheme = runtime.NewScheme()
+ schemeErr := v1alpha1.AddToScheme(scheme)
+ Expect(schemeErr).NotTo(HaveOccurred())
+
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{}
+ existingCollaborators = []*github.User{}
+ orgMembers = []*github.User{}
+ addCalls = []ghclientmock.CollaboratorCall{}
+ removeCalls = []ghclientmock.CollaboratorCall{}
+ listErr = nil
+ addErr = nil
+ removeErr = nil
+
+ mockClient.GetAllRepositoryCollaboratorsFunc = func(ctx context.Context, owner, repo string) ([]*github.User, error) {
+ return existingCollaborators, listErr
+ }
+ mockClient.ListMembersFunc = func(ctx context.Context, org string) ([]*github.User, error) {
+ return orgMembers, nil
+ }
+ mockClient.AddRepositoryCollaboratorFunc = func(ctx context.Context, owner, repo, username, permission string) error {
+ addCalls = append(addCalls, ghclientmock.CollaboratorCall{Method: "AddRepositoryCollaborator", Owner: owner, Repo: repo, Username: username, Permission: permission})
+ return addErr
+ }
+ mockClient.RemoveRepositoryCollaboratorFunc = func(ctx context.Context, owner, repo, username string) error {
+ removeCalls = append(removeCalls, ghclientmock.CollaboratorCall{Method: "RemoveRepositoryCollaborator", Owner: owner, Repo: repo, Username: username})
+ return removeErr
+ }
+ })
+
+ JustBeforeEach(func() {
+ repo = &v1alpha1.Repository{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-repo",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.RepositorySpec{
+ Name: "test-repo",
+ OrganizationRef: v1alpha1.OrganizationRef{Name: "acme-corp"},
+ Collaborators: desiredCollaborators,
+ },
+ }
+
+ if org == nil {
+ org = &v1alpha1.Organization{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "acme-corp",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.OrganizationSpec{
+ Name: "acme-corp",
+ },
+ }
+ }
+
+ k8sClient = fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(repo, org).
+ WithStatusSubresource(repo).
+ Build()
+
+ rec = &GitHubRepoReconciler{
+ GitHub: reconciler.GitHub[GitHubRepoIdentifier]{
+ Client: mockClient,
+ Resource: GitHubRepoIdentifier{
+ Owner: "test-org",
+ Name: "test-repo",
+ },
+ },
+ Kubernetes: reconciler.Kubernetes[*v1alpha1.Repository]{
+ Client: k8sClient,
+ Resource: repo,
+ },
+ }
+
+ err = rec.reconcileCollaborators(ctx)
+ })
+
+ Context("when desired collaborators match current collaborators", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ Permission: "push",
+ }}
+ existingCollaborators = []*github.User{{
+ Login: new("alice"),
+ RoleName: new("push"),
+ }}
+ })
+
+ It("should not call add or remove", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when collaborator is missing", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ Permission: "maintain",
+ }}
+ orgMembers = []*github.User{{Login: new("alice")}}
+ })
+
+ It("should add collaborator with requested permission", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Username).To(Equal("alice"))
+ Expect(addCalls[0].Permission).To(Equal("maintain"))
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when collaborator is missing and not an organization member", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ Permission: "maintain",
+ }}
+ orgMembers = []*github.User{{Login: new("bob")}}
+ })
+
+ It("should not add the collaborator", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when permission differs", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ Permission: "admin",
+ }}
+ existingCollaborators = []*github.User{{
+ Login: new("alice"),
+ RoleName: new("push"),
+ }}
+ })
+
+ It("should update collaborator permission", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Username).To(Equal("alice"))
+ Expect(addCalls[0].Permission).To(Equal("admin"))
+ })
+ })
+
+ Context("when permission is omitted", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ }}
+ orgMembers = []*github.User{{Login: new("alice")}}
+ })
+
+ It("should default to pull", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Username).To(Equal("alice"))
+ Expect(addCalls[0].Permission).To(Equal("pull"))
+ })
+ })
+
+ Context("when repository has extra collaborators", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ Permission: "push",
+ }}
+ existingCollaborators = []*github.User{
+ {Login: new("alice"), RoleName: new("push")},
+ {Login: new("bob"), RoleName: new("pull")},
+ }
+ })
+
+ It("should remove collaborators not in desired spec", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(HaveLen(1))
+ Expect(removeCalls[0].Username).To(Equal("bob"))
+ })
+ })
+
+ Context("when collaborators is nil (not specified)", func() {
+ BeforeEach(func() {
+ desiredCollaborators = nil
+ existingCollaborators = []*github.User{
+ {Login: new("bob"), RoleName: new("pull")},
+ }
+ })
+
+ It("should skip reconciliation and leave GitHub untouched", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when collaborators is an explicit empty list", func() {
+ BeforeEach(func() {
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{}
+ existingCollaborators = []*github.User{
+ {Login: new("bob"), RoleName: new("pull")},
+ }
+ })
+
+ It("should remove all existing collaborators", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(HaveLen(1))
+ Expect(removeCalls[0].Username).To(Equal("bob"))
+ })
+ })
+
+ Context("when listing collaborators fails", func() {
+ BeforeEach(func() {
+ listErr = errors.New("list collaborators failed")
+ })
+
+ It("should return the error", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("list collaborators failed"))
+ })
+ })
+
+ Context("when add collaborator fails", func() {
+ BeforeEach(func() {
+ addErr = errors.New("add collaborator failed")
+ desiredCollaborators = []v1alpha1.RepositoryCollaboratorPermission{{
+ Username: "alice",
+ Permission: "pull",
+ }}
+ orgMembers = []*github.User{{Login: new("alice")}}
+ })
+
+ It("should return the error", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("add collaborator failed"))
+ })
+ })
+
+ Context("when remove collaborator fails", func() {
+ BeforeEach(func() {
+ removeErr = errors.New("remove collaborator failed")
+ existingCollaborators = []*github.User{{
+ Login: new("bob"),
+ RoleName: new("pull"),
+ }}
+ })
+
+ It("should return the error", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("remove collaborator failed"))
+ })
+ })
+})
diff --git a/internal/reconciler/reporec/rec_teams.go b/internal/reconciler/reporec/rec_teams.go
new file mode 100644
index 0000000..6b5726c
--- /dev/null
+++ b/internal/reconciler/reporec/rec_teams.go
@@ -0,0 +1,109 @@
+package reporec
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/Interhyp/git-hubby/api/v1alpha1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logPkg "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+const defaultRepositoryTeamPermission = "pull"
+
+func (r *GitHubRepoReconciler) reconcileTeams(ctx context.Context) error {
+ log := logPkg.FromContext(ctx)
+
+ if r.Kubernetes.Resource.Spec.Teams == nil {
+ log.V(1).Info("Teams not specified in repository spec, skipping team reconciliation")
+ return nil
+ }
+
+ log.V(1).Info("Reconciling repository teams on GitHub")
+
+ existingTeams, err := r.GitHub.Client.GetAllRepositoryTeams(ctx, r.GitHub.Resource.Owner, r.GitHub.Resource.Name)
+ if err != nil {
+ log.Error(err, "failed to list repository teams from GitHub")
+ return err
+ }
+
+ existingPermissionsBySlug := make(map[string]string)
+ teamsToDelete := make(map[string]string)
+ for _, team := range existingTeams {
+ if team == nil || team.Slug == nil {
+ continue
+ }
+ slug := team.GetSlug()
+ permission := team.GetPermission()
+ existingPermissionsBySlug[slug] = permission
+ teamsToDelete[slug] = slug
+ }
+
+ var errs []error
+ for _, desiredTeam := range r.Kubernetes.Resource.Spec.Teams {
+ var team v1alpha1.Team
+ if err := r.Kubernetes.Client.Get(ctx, client.ObjectKey{
+ Name: desiredTeam.TeamRef.Name,
+ Namespace: r.Kubernetes.Resource.Namespace,
+ }, &team); err != nil {
+ log.Error(err, "unable to fetch Team referenced in repository spec", "teamRef", desiredTeam.TeamRef.Name)
+ errs = append(errs, fmt.Errorf("failed to get team %s: %w", desiredTeam.TeamRef.Name, err))
+ continue
+ }
+
+ if !teamReferencesOrganization(team, r.Kubernetes.Resource.Spec.OrganizationRef) {
+ log.Info("WARNING: Team referenced in repository spec does not reference the repository's organization", "teamRef", desiredTeam.TeamRef.Name, "organization", r.Kubernetes.Resource.Spec.OrganizationRef.Name)
+ continue
+ }
+
+ if team.Status.Slug == nil || *team.Status.Slug == "" {
+ log.Info("WARNING: Team referenced in repository spec has not been synced to GitHub yet", "teamRef", desiredTeam.TeamRef.Name)
+ continue
+ }
+ slug := *team.Status.Slug
+
+ desiredPermission := repositoryTeamPermission(desiredTeam)
+ if currentPermission, exists := existingPermissionsBySlug[slug]; exists && currentPermission == desiredPermission {
+ delete(teamsToDelete, slug)
+ continue
+ }
+
+ if err := r.GitHub.Client.AddRepositoryTeam(ctx, r.GitHub.Resource.Owner, slug, r.GitHub.Resource.Owner, r.GitHub.Resource.Name, desiredPermission); err != nil {
+ log.Error(err, "failed to add or update team permission on repository", "teamSlug", slug, "permission", desiredPermission)
+ return err
+ }
+ delete(teamsToDelete, slug)
+ }
+
+ for _, slug := range teamsToDelete {
+ if err := r.GitHub.Client.RemoveRepositoryTeam(ctx, r.GitHub.Resource.Owner, slug, r.GitHub.Resource.Owner, r.GitHub.Resource.Name); err != nil {
+ log.Error(err, "failed to remove team from repository", "teamSlug", slug)
+ return err
+ }
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ log.V(1).Info("Successfully reconciled repository teams on GitHub")
+ return nil
+}
+
+// teamReferencesOrganization reports whether the given Team references the same Organization CRD as orgRef.
+func teamReferencesOrganization(team v1alpha1.Team, orgRef v1alpha1.OrganizationRef) bool {
+ for _, ref := range team.Spec.OrganizationRefs {
+ if ref.Name == orgRef.Name {
+ return true
+ }
+ }
+ return false
+}
+
+func repositoryTeamPermission(team v1alpha1.RepositoryTeamPermission) string {
+ if team.Permission == "" {
+ return defaultRepositoryTeamPermission
+ }
+ return team.Permission
+}
diff --git a/internal/reconciler/reporec/rec_teams_test.go b/internal/reconciler/reporec/rec_teams_test.go
new file mode 100644
index 0000000..96b86bc
--- /dev/null
+++ b/internal/reconciler/reporec/rec_teams_test.go
@@ -0,0 +1,377 @@
+package reporec
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Interhyp/git-hubby/api/v1alpha1"
+ "github.com/Interhyp/git-hubby/internal/reconciler"
+ "github.com/Interhyp/git-hubby/test/mock/ghclientmock"
+ "github.com/google/go-github/v89/github"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+var _ = Describe("ReconcileTeams", func() {
+ var (
+ ctx context.Context
+ mockClient *ghclientmock.MockGitHubClientWrapper
+ k8sClient client.Client
+ rec *GitHubRepoReconciler
+ scheme *runtime.Scheme
+ repo *v1alpha1.Repository
+
+ desiredTeams []v1alpha1.RepositoryTeamPermission
+ teamResources []*v1alpha1.Team
+ repositoryTeams []*github.Team
+
+ addCalls []ghclientmock.TeamCall
+ removeCalls []ghclientmock.TeamCall
+
+ listRepoErr error
+ addErr error
+ removeErr error
+
+ err error
+ )
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ mockClient = ghclientmock.NewMockGitHubClientWrapper()
+
+ scheme = runtime.NewScheme()
+ schemeErr := v1alpha1.AddToScheme(scheme)
+ Expect(schemeErr).NotTo(HaveOccurred())
+
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{}
+ teamResources = []*v1alpha1.Team{
+ {
+ ObjectMeta: metav1.ObjectMeta{Name: "platform-team", Namespace: "default"},
+ Spec: v1alpha1.TeamSpec{
+ Name: "platform-team",
+ OrganizationRefs: []v1alpha1.OrganizationRef{{Name: "acme-corp"}},
+ },
+ Status: v1alpha1.TeamStatus{Slug: new("platform-team")},
+ },
+ }
+ repositoryTeams = []*github.Team{}
+
+ addCalls = []ghclientmock.TeamCall{}
+ removeCalls = []ghclientmock.TeamCall{}
+
+ listRepoErr = nil
+ addErr = nil
+ removeErr = nil
+
+ mockClient.GetAllRepositoryTeamsFunc = func(ctx context.Context, owner, repo string) ([]*github.Team, error) {
+ return repositoryTeams, listRepoErr
+ }
+
+ mockClient.AddRepositoryTeamFunc = func(ctx context.Context, org, slug, owner, repo, permission string) error {
+ addCalls = append(addCalls, ghclientmock.TeamCall{Method: "AddRepositoryTeam", Org: org, Slug: slug, Owner: owner, Repo: repo, Permission: permission})
+ return addErr
+ }
+
+ mockClient.RemoveTeamFromRepoFunc = func(ctx context.Context, org, slug, owner, repo string) error {
+ removeCalls = append(removeCalls, ghclientmock.TeamCall{Method: "RemoveRepositoryTeam", Org: org, Slug: slug, Owner: owner, Repo: repo})
+ return removeErr
+ }
+ })
+
+ JustBeforeEach(func() {
+ repo = &v1alpha1.Repository{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-repo",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.RepositorySpec{
+ Name: "test-repo",
+ OrganizationRef: v1alpha1.OrganizationRef{Name: "acme-corp"},
+ Teams: desiredTeams,
+ },
+ }
+
+ objs := []client.Object{repo}
+ for _, team := range teamResources {
+ objs = append(objs, team)
+ }
+
+ k8sClient = fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(objs...).
+ WithStatusSubresource(repo).
+ Build()
+
+ rec = &GitHubRepoReconciler{
+ GitHub: reconciler.GitHub[GitHubRepoIdentifier]{
+ Client: mockClient,
+ Resource: GitHubRepoIdentifier{
+ Owner: "test-org",
+ Name: "test-repo",
+ },
+ },
+ Kubernetes: reconciler.Kubernetes[*v1alpha1.Repository]{
+ Client: k8sClient,
+ Resource: repo,
+ },
+ }
+
+ err = rec.reconcileTeams(ctx)
+ })
+
+ Context("when desired state matches existing repository team permissions", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "push",
+ }}
+ repositoryTeams = []*github.Team{{
+ Slug: new("platform-team"),
+ Permission: new("push"),
+ }}
+ })
+
+ It("should not change team assignments", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when repository team is missing", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "maintain",
+ }}
+ })
+
+ It("should add team permission", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Slug).To(Equal("platform-team"))
+ Expect(addCalls[0].Permission).To(Equal("maintain"))
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when permission differs", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "admin",
+ }}
+ repositoryTeams = []*github.Team{{
+ Slug: new("platform-team"),
+ Permission: new("push"),
+ }}
+ })
+
+ It("should update team permission", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Permission).To(Equal("admin"))
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when team has empty permission in spec", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ }}
+ })
+
+ It("should default permission to pull", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Permission).To(Equal("pull"))
+ })
+ })
+
+ Context("when repository has extra team assignments", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "push",
+ }}
+ repositoryTeams = []*github.Team{
+ {Slug: new("platform-team"), Permission: new("push")},
+ {Slug: new("security-team"), Permission: new("triage")},
+ }
+ })
+
+ It("should remove teams not present in spec", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(HaveLen(1))
+ Expect(removeCalls[0].Slug).To(Equal("security-team"))
+ })
+ })
+
+ Context("when desired team is not found in organization", func() {
+ BeforeEach(func() {
+ teamResources = []*v1alpha1.Team{
+ {
+ ObjectMeta: metav1.ObjectMeta{Name: "does-not-exist", Namespace: "default"},
+ Spec: v1alpha1.TeamSpec{
+ Name: "does-not-exist",
+ OrganizationRefs: []v1alpha1.OrganizationRef{{Name: "some-other-org"}},
+ },
+ Status: v1alpha1.TeamStatus{Slug: new("does-not-exist")},
+ },
+ }
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "does-not-exist"},
+ Permission: "pull",
+ }}
+ })
+
+ It("should skip unknown teams and not fail", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when Team CR has not synced its slug to GitHub yet", func() {
+ BeforeEach(func() {
+ teamResources = []*v1alpha1.Team{
+ {
+ ObjectMeta: metav1.ObjectMeta{Name: "platform-team", Namespace: "default"},
+ Spec: v1alpha1.TeamSpec{
+ Name: "platform-team",
+ OrganizationRefs: []v1alpha1.OrganizationRef{{Name: "acme-corp"}},
+ },
+ },
+ }
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "pull",
+ }}
+ })
+
+ It("should skip the team and not fail", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when referenced Team CRD does not exist", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "missing-team-cr"},
+ Permission: "pull",
+ }}
+ })
+
+ It("should return an error", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when one of several referenced Team CRDs does not exist", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{
+ {
+ TeamRef: v1alpha1.TeamRef{Name: "missing-team-cr"},
+ Permission: "pull",
+ },
+ {
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "push",
+ },
+ }
+ repositoryTeams = []*github.Team{
+ {Slug: new("security-team"), Permission: new("triage")},
+ }
+ })
+
+ It("should still reconcile the remaining teams and prune stale ones", func() {
+ Expect(err).To(HaveOccurred())
+ Expect(addCalls).To(HaveLen(1))
+ Expect(addCalls[0].Slug).To(Equal("platform-team"))
+ Expect(addCalls[0].Permission).To(Equal("push"))
+ Expect(removeCalls).To(HaveLen(1))
+ Expect(removeCalls[0].Slug).To(Equal("security-team"))
+ })
+ })
+
+ Context("when teams is nil (not specified)", func() {
+ BeforeEach(func() {
+ desiredTeams = nil
+ repositoryTeams = []*github.Team{{
+ Slug: new("platform-team"),
+ Permission: new("push"),
+ }}
+ })
+
+ It("should skip reconciliation and leave GitHub untouched", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(BeEmpty())
+ })
+ })
+
+ Context("when teams is an explicit empty list", func() {
+ BeforeEach(func() {
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{}
+ repositoryTeams = []*github.Team{{
+ Slug: new("platform-team"),
+ Permission: new("push"),
+ }}
+ })
+
+ It("should remove all existing team permissions", func() {
+ Expect(err).NotTo(HaveOccurred())
+ Expect(addCalls).To(BeEmpty())
+ Expect(removeCalls).To(HaveLen(1))
+ Expect(removeCalls[0].Slug).To(Equal("platform-team"))
+ })
+ })
+
+ Context("when listing repository teams fails", func() {
+ BeforeEach(func() {
+ listRepoErr = errors.New("list repo teams failed")
+ })
+
+ It("should return the error", func() {
+ Expect(err).To(MatchError("list repo teams failed"))
+ })
+ })
+
+ Context("when adding team permission fails", func() {
+ BeforeEach(func() {
+ addErr = errors.New("add team failed")
+ desiredTeams = []v1alpha1.RepositoryTeamPermission{{
+ TeamRef: v1alpha1.TeamRef{Name: "platform-team"},
+ Permission: "pull",
+ }}
+ })
+
+ It("should return the error", func() {
+ Expect(err).To(MatchError("add team failed"))
+ })
+ })
+
+ Context("when removing team permission fails", func() {
+ BeforeEach(func() {
+ removeErr = errors.New("remove team failed")
+ repositoryTeams = []*github.Team{{
+ Slug: new("platform-team"),
+ Permission: new("pull"),
+ }}
+ })
+
+ It("should return the error", func() {
+ Expect(err).To(MatchError("remove team failed"))
+ })
+ })
+})
diff --git a/internal/reconciler/reporec/reconciler.go b/internal/reconciler/reporec/reconciler.go
index 75fc811..c415010 100644
--- a/internal/reconciler/reporec/reconciler.go
+++ b/internal/reconciler/reporec/reconciler.go
@@ -32,8 +32,12 @@ func (g *GitHubRepoIdentifier) GetID() int64 {
}
type GitHubRepoReconciler struct {
- GitHub reconciler.GitHub[GitHubRepoIdentifier]
- Kubernetes reconciler.Kubernetes[*githubv1alpha1.Repository]
+ GitHub reconciler.GitHub[GitHubRepoIdentifier]
+ Kubernetes reconciler.Kubernetes[*githubv1alpha1.Repository]
+ // MemberSuffix is the global suffix appended to team member usernames (e.g. "@acme.com").
+ // It is superseded by the per-organization spec.memberSuffix field when set.
+ // Corresponds to the GITHUB_MEMBER_SUFFIX environment variable loaded via internal/config.
+ MemberSuffix string
FinalizeMode reconciler.RepositoryFinalizerMode
Features config.Features
}
@@ -68,6 +72,14 @@ func (r *GitHubRepoReconciler) RequiredReconciliations() []reconciler.ParallelRe
},
},
{
+ {
+ Function: r.reconcileTeams,
+ Condition: conditions.TypeTeamsSynced,
+ },
+ {
+ Function: r.reconcileCollaborators,
+ Condition: conditions.TypeCollaboratorsSynced,
+ },
{
Function: r.reconcileCustomProperties,
Condition: conditions.TypeCustomPropertiesValuesSynced,
diff --git a/test/mock/ghclientmock/mock_factory.go b/test/mock/ghclientmock/mock_factory.go
index 8024b1d..0f022e3 100644
--- a/test/mock/ghclientmock/mock_factory.go
+++ b/test/mock/ghclientmock/mock_factory.go
@@ -46,11 +46,17 @@ type MockGitHubClientWrapper struct {
ListMembersFunc func(ctx context.Context, org string) ([]*github.User, error)
// Repository mocks
- GetRepositoryFunc func(ctx context.Context, owner, repo string) (*github.Repository, error)
- CreateRepositoryFunc func(ctx context.Context, org string, repo *github.Repository) (*github.Repository, error)
- EditRepositoryFunc func(ctx context.Context, owner, repo string, repository *github.Repository) (*github.Repository, error)
- DeleteRepositoryFunc func(ctx context.Context, owner, repo string) error
- GetOrgRepositoriesFunc func(ctx context.Context, org string) ([]*github.Repository, error)
+ GetRepositoryFunc func(ctx context.Context, owner, repo string) (*github.Repository, error)
+ CreateRepositoryFunc func(ctx context.Context, org string, repo *github.Repository) (*github.Repository, error)
+ EditRepositoryFunc func(ctx context.Context, owner, repo string, repository *github.Repository) (*github.Repository, error)
+ DeleteRepositoryFunc func(ctx context.Context, owner, repo string) error
+ GetOrgRepositoriesFunc func(ctx context.Context, org string) ([]*github.Repository, error)
+ GetAllRepositoryTeamsFunc func(ctx context.Context, owner, repo string) ([]*github.Team, error)
+ AddRepositoryTeamFunc func(ctx context.Context, org, slug, owner, repo, permission string) error
+ RemoveTeamFromRepoFunc func(ctx context.Context, org, slug, owner, repo string) error
+ GetAllRepositoryCollaboratorsFunc func(ctx context.Context, owner, repo string) ([]*github.User, error)
+ AddRepositoryCollaboratorFunc func(ctx context.Context, owner, repo, username, permission string) error
+ RemoveRepositoryCollaboratorFunc func(ctx context.Context, owner, repo, username string) error
// Topic mocks
GetAllTopicsFunc func(ctx context.Context, owner, repo string) ([]string, error)
diff --git a/test/mock/ghclientmock/mock_repository.go b/test/mock/ghclientmock/mock_repository.go
index 5178b11..f4a3694 100644
--- a/test/mock/ghclientmock/mock_repository.go
+++ b/test/mock/ghclientmock/mock_repository.go
@@ -196,3 +196,36 @@ func (m *MockGitHubClientWrapper) GetOrgRepositories(ctx context.Context, org st
// Default implementation - return empty list
return make([]*github.Repository, 0), nil
}
+
+func (m *MockGitHubClientWrapper) GetAllRepositoryCollaborators(ctx context.Context, owner, repo string) ([]*github.User, error) {
+ m.recordRepoCall(RepoCall{Method: "GetAllRepositoryCollaborators", Owner: owner, Repo: repo})
+
+ if m.GetAllRepositoryCollaboratorsFunc != nil {
+ return m.GetAllRepositoryCollaboratorsFunc(ctx, owner, repo)
+ }
+
+ // Default implementation - return empty list
+ return make([]*github.User, 0), nil
+}
+
+func (m *MockGitHubClientWrapper) AddRepositoryCollaborator(ctx context.Context, owner, repo, username, permission string) error {
+ m.recordRepoCall(RepoCall{Method: "AddRepositoryCollaborator", Owner: owner, Repo: repo})
+
+ if m.AddRepositoryCollaboratorFunc != nil {
+ return m.AddRepositoryCollaboratorFunc(ctx, owner, repo, username, permission)
+ }
+
+ // Default implementation - return success
+ return nil
+}
+
+func (m *MockGitHubClientWrapper) RemoveRepositoryCollaborator(ctx context.Context, owner, repo, username string) error {
+ m.recordRepoCall(RepoCall{Method: "RemoveRepositoryCollaborator", Owner: owner, Repo: repo})
+
+ if m.RemoveRepositoryCollaboratorFunc != nil {
+ return m.RemoveRepositoryCollaboratorFunc(ctx, owner, repo, username)
+ }
+
+ // Default implementation - return success
+ return nil
+}
diff --git a/test/mock/ghclientmock/mock_team.go b/test/mock/ghclientmock/mock_team.go
index eb56211..93c7f55 100644
--- a/test/mock/ghclientmock/mock_team.go
+++ b/test/mock/ghclientmock/mock_team.go
@@ -97,6 +97,40 @@ func (m *MockGitHubClientWrapper) RemoveTeamMember(ctx context.Context, org stri
return nil
}
+// Repository team permissions operations
+func (m *MockGitHubClientWrapper) GetAllRepositoryTeams(ctx context.Context, owner, repo string) ([]*github.Team, error) {
+ m.recordTeamCall(TeamCall{Method: "GetAllRepositoryTeams", Owner: owner, Repo: repo})
+
+ if m.GetAllRepositoryTeamsFunc != nil {
+ return m.GetAllRepositoryTeamsFunc(ctx, owner, repo)
+ }
+
+ // Default implementation returns empty list
+ return make([]*github.Team, 0), nil
+}
+
+func (m *MockGitHubClientWrapper) AddRepositoryTeam(ctx context.Context, org, slug, owner, repo, permission string) error {
+ m.recordTeamCall(TeamCall{Method: "AddRepositoryTeam", Org: org, Slug: slug, Owner: owner, Repo: repo, Permission: permission})
+
+ if m.AddRepositoryTeamFunc != nil {
+ return m.AddRepositoryTeamFunc(ctx, org, slug, owner, repo, permission)
+ }
+
+ // Default implementation returns nil
+ return nil
+}
+
+func (m *MockGitHubClientWrapper) RemoveRepositoryTeam(ctx context.Context, org, slug, owner, repo string) error {
+ m.recordTeamCall(TeamCall{Method: "RemoveRepositoryTeam", Org: org, Slug: slug, Owner: owner, Repo: repo})
+
+ if m.RemoveTeamFromRepoFunc != nil {
+ return m.RemoveTeamFromRepoFunc(ctx, org, slug, owner, repo)
+ }
+
+ // Default implementation returns nil
+ return nil
+}
+
// Team IDP group operations
func (m *MockGitHubClientWrapper) GetExternalGroupsForTeamBySlug(ctx context.Context, org string, slug string) ([]*github.ExternalGroup, error) {
m.recordExternalGroupCall(ExternalGroupCall{Method: "GetExternalGroupsForTeamBySlug", Org: org, Slug: slug})
diff --git a/test/mock/ghclientmock/mock_types.go b/test/mock/ghclientmock/mock_types.go
index fcfd463..fe9ce35 100644
--- a/test/mock/ghclientmock/mock_types.go
+++ b/test/mock/ghclientmock/mock_types.go
@@ -57,6 +57,17 @@ type TeamCall struct {
Org string
Slug string
Description string
+ Owner string
+ Repo string
+ Permission string
+}
+
+type CollaboratorCall struct {
+ Method string
+ Owner string
+ Repo string
+ Username string
+ Permission string
}
type TeamMemberCall struct {