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 {