Repository: crossplane-contrib/provider-gitlab
Pull request #214 (2025-07-17 05:40:48)

feat: add approvals MR for creating rules on projects
<!--
Thank you for helping to improve Crossplane!

Please read through https://git.io/fj2m9 if this is your first time opening a
Crossplane pull request. Find us in https://slack.crossplane.io/messages/dev if
you need any help contributing.
-->

### Description of your changes

<!--
Briefly describe what this pull request does. Be sure to direct your reviewers'
attention to anything that needs special consideration.

We love pull requests that resolve an open Crossplane issue. If yours does, you
can uncomment the below line to indicate which issue your PR fixes, for example
"Fixes #500":

-->
Fixes #

I have:

- [ ] Read and followed Crossplane's [contribution process].
- [ ] Run `make reviewable test` to ensure this PR is ready for review.

### How has this code been tested

<!--
Before reviewers can be confident in the correctness of this pull request, it
needs to tested and shown to be correct. Briefly describe the testing that has
already been done or which is planned for this change.
-->

[contribution process]: https://git.io/fj2m9



Patch:
diff --git a/apis/projects/v1alpha1/approval_type.go b/apis/projects/v1alpha1/approval_type.go
new file mode 100644
index 0000000..aa90a68
--- /dev/null
+++ b/apis/projects/v1alpha1/approval_type.go
@@ -0,0 +1,123 @@
+/*
+Copyright 2021 The Crossplane Authors.
+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.
+*/
+
+package v1alpha1
+
+import (
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type RuleType string
+
+const (
+	RuleTypeAnyApprover    RuleType = "any_approver"
+	RuleTypeRegular        RuleType = "regular"
+	RuleTypeReportApprover RuleType = "report_approver"
+)
+
+// A ApprovalRuleParameters defines the desired state of a Gitlab Project Member.
+type ApprovalRuleParameters struct {
+
+	// The ID of the project owned by the authenticated user.
+	// +optional
+	// +immutable
+	ProjectID *int `json:"projectId,omitempty"`
+
+	// ProjectIDRef is a reference to a project to retrieve its projectId
+	// +optional
+	// +immutable
+	ProjectIDRef *xpv1.Reference `json:"projectIdRef,omitempty"`
+
+	// ProjectIDSelector selects reference to a project to retrieve its projectId.
+	// +optional
+	ProjectIDSelector *xpv1.Selector `json:"projectIdSelector,omitempty"`
+
+	// The number of required approvals for this rule.
+	ApprovalsRequired *int `json:"approvalsRequired,omitempty"`
+
+	// The name of the approval rule
+	Name *string `json:"name,omitempty"`
+
+	// If true, applies the rule to all protected branches and ignores the protected_branch_ids attribute.
+	// +optional
+	AppliesToAllProtectedBranches *bool `json:"appliesToAllProtectedBranches,omitempty"`
+
+	// The IDs of groups as approvers.
+	// +optional
+	GroupIDs *[]int `json:"groupIds,omitempty"`
+
+	// The IDs of protected branches to scope the rule by.
+	// +optional
+	ProtectedBranchIDs *[]int `json:"protectedBranchIds,omitempty"`
+
+	// The rule type. Supported values include any_approver, regular, and report_approver
+	// +optional
+	// +immutable
+	RuleType *RuleType `json:"ruleType,omitempty"`
+
+	// The IDs of users as approvers. If used with usernames, adds both lists of users.
+	// +optional
+	UserIDs *[]int `json:"userIds,omitempty"`
+
+	// The usernames of approvers. If used with user_ids, adds both lists of users.
+	// +optional
+	Usernames *[]string `json:"usernames,omitempty"`
+}
+
+// ApprovalRuleObservation represents a project member.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ce/api/projects.html#list-project-team-members
+type ApprovalRuleObservation struct{}
+
+// A ApprovalRuleSpec defines the desired state of a Gitlab Project Member.
+type ApprovalRuleSpec struct {
+	xpv1.ResourceSpec `json:",inline"`
+	ForProvider       ApprovalRuleParameters `json:"forProvider"`
+}
+
+// A ApprovalRuleStatus represents the observed state of a Gitlab Project Member.
+type ApprovalRuleStatus struct {
+	xpv1.ResourceStatus `json:",inline"`
+	AtProvider          ApprovalRuleObservation `json:"atProvider,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// A ApprovalRule is a managed resource that represents a Gitlab Project ApprovalRule
+// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
+// +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status"
+// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:printcolumn:name="Project ID",type="integer",JSONPath=".spec.forProvider.projectId"
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:scope=Cluster,categories={crossplane,managed,gitlab}
+type ApprovalRule struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   ApprovalRuleSpec   `json:"spec"`
+	Status ApprovalRuleStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// ApprovalRuleList contains a list of Approval Rules items
+type ApprovalRuleList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []ApprovalRule `json:"items"`
+}
diff --git a/apis/projects/v1alpha1/project_types.go b/apis/projects/v1alpha1/project_types.go
index bee9afd..bbb3c5d 100644
--- a/apis/projects/v1alpha1/project_types.go
+++ b/apis/projects/v1alpha1/project_types.go
@@ -153,17 +153,9 @@ type ProjectParameters struct {
 	// +optional
 	AllowMergeOnSkippedPipeline *bool `json:"allowMergeOnSkippedPipeline,omitempty"`
 
-	// NOTE: ApprovalsBeforeMerge is deprecated and approval rules should be
-	// used instead which has its own API.
-	//
-	// TODO: Implement approval rules API, maybe as separate MR or as part of
-	// this kind.
-	//
-	// TODO: Mark ApprovalsBeforeMerge as deprecated but keep it and replace it
-	// internally with a call to the approval rules API.
-
 	// How many approvers should approve merge request by default.More actions
 	// To configure approval rules, see Merge request approvals API.
+	// Deprecated: use Approvals Resource instead
 	// +optional
 	ApprovalsBeforeMerge *int `json:"approvalsBeforeMerge,omitempty"`
 
diff --git a/apis/projects/v1alpha1/referencers.go b/apis/projects/v1alpha1/referencers.go
index c523677..f9d2fd3 100644
--- a/apis/projects/v1alpha1/referencers.go
+++ b/apis/projects/v1alpha1/referencers.go
@@ -70,7 +70,29 @@ func (mg *Hook) ResolveReferences(ctx context.Context, c client.Reader) error {
 	mg.Spec.ForProvider.ProjectIDRef = rsp.ResolvedReference
 
 	return nil
+}
+
+// ResolveReferences of this Hook
+func (mg *ApprovalRule) ResolveReferences(ctx context.Context, c client.Reader) error {
+	r := reference.NewAPIResolver(c, mg)
 
+	// resolve spec.forProvider.projectIdRef
+	rsp, err := r.Resolve(ctx, reference.ResolutionRequest{
+		CurrentValue: fromPtrValue(mg.Spec.ForProvider.ProjectID),
+		Reference:    mg.Spec.ForProvider.ProjectIDRef,
+		Selector:     mg.Spec.ForProvider.ProjectIDSelector,
+		To:           reference.To{Managed: &Project{}, List: &ProjectList{}},
+		Extract:      reference.ExternalName(),
+	})
+
+	if err != nil {
+		return errors.Wrap(err, "spec.forProvider.projectId")
+	}
+
+	mg.Spec.ForProvider.ProjectID = toPtrValue(rsp.ResolvedValue)
+	mg.Spec.ForProvider.ProjectIDRef = rsp.ResolvedReference
+
+	return nil
 }
 
 // ResolveReferences of this Project
diff --git a/apis/projects/v1alpha1/register.go b/apis/projects/v1alpha1/register.go
index 4a8cbc1..1ebc314 100644
--- a/apis/projects/v1alpha1/register.go
+++ b/apis/projects/v1alpha1/register.go
@@ -61,6 +61,14 @@ var (
 	MemberGroupVersionKind = SchemeGroupVersion.WithKind(MemberKind)
 )
 
+// Approval Rule type metadata
+var (
+	ApprovalRuleKind             = reflect.TypeOf(ApprovalRule{}).Name()
+	ApprovalRuleGroupKind        = schema.GroupKind{Group: Group, Kind: MemberKind}.String()
+	ApprovalRuleKindAPIVersion   = MemberKind + "." + SchemeGroupVersion.String()
+	ApprovalRuleGroupVersionKind = SchemeGroupVersion.WithKind(ApprovalRuleKind)
+)
+
 // Deploy Token type metadata
 var (
 	DeployTokenKind             = reflect.TypeOf(DeployToken{}).Name()
@@ -105,6 +113,7 @@ func init() {
 	SchemeBuilder.Register(&Project{}, &ProjectList{})
 	SchemeBuilder.Register(&Hook{}, &HookList{})
 	SchemeBuilder.Register(&Member{}, &MemberList{})
+	SchemeBuilder.Register(&ApprovalRule{}, &ApprovalRuleList{})
 	SchemeBuilder.Register(&DeployToken{}, &DeployTokenList{})
 	SchemeBuilder.Register(&Variable{}, &VariableList{})
 	SchemeBuilder.Register(&DeployKey{}, &DeployKeyList{})
diff --git a/apis/projects/v1alpha1/zz_generated.deepcopy.go b/apis/projects/v1alpha1/zz_generated.deepcopy.go
index 87ba7c7..41a0c3e 100644
--- a/apis/projects/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/projects/v1alpha1/zz_generated.deepcopy.go
@@ -182,6 +182,200 @@ func (in *AccessTokenStatus) DeepCopy() *AccessTokenStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApprovalRule) DeepCopyInto(out *ApprovalRule) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalRule.
+func (in *ApprovalRule) DeepCopy() *ApprovalRule {
+	if in == nil {
+		return nil
+	}
+	out := new(ApprovalRule)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ApprovalRule) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApprovalRuleList) DeepCopyInto(out *ApprovalRuleList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ApprovalRule, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalRuleList.
+func (in *ApprovalRuleList) DeepCopy() *ApprovalRuleList {
+	if in == nil {
+		return nil
+	}
+	out := new(ApprovalRuleList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ApprovalRuleList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApprovalRuleObservation) DeepCopyInto(out *ApprovalRuleObservation) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalRuleObservation.
+func (in *ApprovalRuleObservation) DeepCopy() *ApprovalRuleObservation {
+	if in == nil {
+		return nil
+	}
+	out := new(ApprovalRuleObservation)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApprovalRuleParameters) DeepCopyInto(out *ApprovalRuleParameters) {
+	*out = *in
+	if in.ProjectID != nil {
+		in, out := &in.ProjectID, &out.ProjectID
+		*out = new(int)
+		**out = **in
+	}
+	if in.ProjectIDRef != nil {
+		in, out := &in.ProjectIDRef, &out.ProjectIDRef
+		*out = new(v1.Reference)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.ProjectIDSelector != nil {
+		in, out := &in.ProjectIDSelector, &out.ProjectIDSelector
+		*out = new(v1.Selector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.ApprovalsRequired != nil {
+		in, out := &in.ApprovalsRequired, &out.ApprovalsRequired
+		*out = new(int)
+		**out = **in
+	}
+	if in.Name != nil {
+		in, out := &in.Name, &out.Name
+		*out = new(string)
+		**out = **in
+	}
+	if in.AppliesToAllProtectedBranches != nil {
+		in, out := &in.AppliesToAllProtectedBranches, &out.AppliesToAllProtectedBranches
+		*out = new(bool)
+		**out = **in
+	}
+	if in.GroupIDs != nil {
+		in, out := &in.GroupIDs, &out.GroupIDs
+		*out = new([]int)
+		if **in != nil {
+			in, out := *in, *out
+			*out = make([]int, len(*in))
+			copy(*out, *in)
+		}
+	}
+	if in.ProtectedBranchIDs != nil {
+		in, out := &in.ProtectedBranchIDs, &out.ProtectedBranchIDs
+		*out = new([]int)
+		if **in != nil {
+			in, out := *in, *out
+			*out = make([]int, len(*in))
+			copy(*out, *in)
+		}
+	}
+	if in.RuleType != nil {
+		in, out := &in.RuleType, &out.RuleType
+		*out = new(RuleType)
+		**out = **in
+	}
+	if in.UserIDs != nil {
+		in, out := &in.UserIDs, &out.UserIDs
+		*out = new([]int)
+		if **in != nil {
+			in, out := *in, *out
+			*out = make([]int, len(*in))
+			copy(*out, *in)
+		}
+	}
+	if in.Usernames != nil {
+		in, out := &in.Usernames, &out.Usernames
+		*out = new([]string)
+		if **in != nil {
+			in, out := *in, *out
+			*out = make([]string, len(*in))
+			copy(*out, *in)
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalRuleParameters.
+func (in *ApprovalRuleParameters) DeepCopy() *ApprovalRuleParameters {
+	if in == nil {
+		return nil
+	}
+	out := new(ApprovalRuleParameters)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApprovalRuleSpec) DeepCopyInto(out *ApprovalRuleSpec) {
+	*out = *in
+	in.ResourceSpec.DeepCopyInto(&out.ResourceSpec)
+	in.ForProvider.DeepCopyInto(&out.ForProvider)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalRuleSpec.
+func (in *ApprovalRuleSpec) DeepCopy() *ApprovalRuleSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ApprovalRuleSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApprovalRuleStatus) DeepCopyInto(out *ApprovalRuleStatus) {
+	*out = *in
+	in.ResourceStatus.DeepCopyInto(&out.ResourceStatus)
+	out.AtProvider = in.AtProvider
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovalRuleStatus.
+func (in *ApprovalRuleStatus) DeepCopy() *ApprovalRuleStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(ApprovalRuleStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ContainerExpirationPolicy) DeepCopyInto(out *ContainerExpirationPolicy) {
 	*out = *in
diff --git a/apis/projects/v1alpha1/zz_generated.managed.go b/apis/projects/v1alpha1/zz_generated.managed.go
index 46fa15e..cc55f82 100644
--- a/apis/projects/v1alpha1/zz_generated.managed.go
+++ b/apis/projects/v1alpha1/zz_generated.managed.go
@@ -80,6 +80,66 @@ func (mg *AccessToken) SetWriteConnectionSecretToReference(r *xpv1.SecretReferen
 	mg.Spec.WriteConnectionSecretToReference = r
 }
 
+// GetCondition of this ApprovalRule.
+func (mg *ApprovalRule) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return mg.Status.GetCondition(ct)
+}
+
+// GetDeletionPolicy of this ApprovalRule.
+func (mg *ApprovalRule) GetDeletionPolicy() xpv1.DeletionPolicy {
+	return mg.Spec.DeletionPolicy
+}
+
+// GetManagementPolicies of this ApprovalRule.
+func (mg *ApprovalRule) GetManagementPolicies() xpv1.ManagementPolicies {
+	return mg.Spec.ManagementPolicies
+}
+
+// GetProviderConfigReference of this ApprovalRule.
+func (mg *ApprovalRule) GetProviderConfigReference() *xpv1.Reference {
+	return mg.Spec.ProviderConfigReference
+}
+
+// GetPublishConnectionDetailsTo of this ApprovalRule.
+func (mg *ApprovalRule) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+	return mg.Spec.PublishConnectionDetailsTo
+}
+
+// GetWriteConnectionSecretToReference of this ApprovalRule.
+func (mg *ApprovalRule) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+	return mg.Spec.WriteConnectionSecretToReference
+}
+
+// SetConditions of this ApprovalRule.
+func (mg *ApprovalRule) SetConditions(c ...xpv1.Condition) {
+	mg.Status.SetConditions(c...)
+}
+
+// SetDeletionPolicy of this ApprovalRule.
+func (mg *ApprovalRule) SetDeletionPolicy(r xpv1.DeletionPolicy) {
+	mg.Spec.DeletionPolicy = r
+}
+
+// SetManagementPolicies of this ApprovalRule.
+func (mg *ApprovalRule) SetManagementPolicies(r xpv1.ManagementPolicies) {
+	mg.Spec.ManagementPolicies = r
+}
+
+// SetProviderConfigReference of this ApprovalRule.
+func (mg *ApprovalRule) SetProviderConfigReference(r *xpv1.Reference) {
+	mg.Spec.ProviderConfigReference = r
+}
+
+// SetPublishConnectionDetailsTo of this ApprovalRule.
+func (mg *ApprovalRule) SetPublishConnectionDetailsTo(r *xpv1.PublishConnectionDetailsTo) {
+	mg.Spec.PublishConnectionDetailsTo = r
+}
+
+// SetWriteConnectionSecretToReference of this ApprovalRule.
+func (mg *ApprovalRule) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) {
+	mg.Spec.WriteConnectionSecretToReference = r
+}
+
 // GetCondition of this DeployKey.
 func (mg *DeployKey) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
 	return mg.Status.GetCondition(ct)
diff --git a/apis/projects/v1alpha1/zz_generated.managedlist.go b/apis/projects/v1alpha1/zz_generated.managedlist.go
index 85db4a8..717ac85 100644
--- a/apis/projects/v1alpha1/zz_generated.managedlist.go
+++ b/apis/projects/v1alpha1/zz_generated.managedlist.go
@@ -29,6 +29,15 @@ func (l *AccessTokenList) GetItems() []resource.Managed {
 	return items
 }
 
+// GetItems of this ApprovalRuleList.
+func (l *ApprovalRuleList) GetItems() []resource.Managed {
+	items := make([]resource.Managed, len(l.Items))
+	for i := range l.Items {
+		items[i] = &l.Items[i]
+	}
+	return items
+}
+
 // GetItems of this DeployKeyList.
 func (l *DeployKeyList) GetItems() []resource.Managed {
 	items := make([]resource.Managed, len(l.Items))
diff --git a/examples/projects/approvalrule.yaml b/examples/projects/approvalrule.yaml
new file mode 100644
index 0000000..875a2bf
--- /dev/null
+++ b/examples/projects/approvalrule.yaml
@@ -0,0 +1,13 @@
+---
+apiVersion: projects.gitlab.crossplane.io/v1alpha1
+kind: ApprovalRule
+metadata:
+  name: example-approval-rule
+  annotations:
+spec:
+  forProvider:
+    projectId: "<example-project-id>"
+    approvalsRequired: 1
+    name: <your-name>
+  providerConfigRef:
+    name: <example-provider-config>
diff --git a/package/crds/projects.gitlab.crossplane.io_approvalrules.yaml b/package/crds/projects.gitlab.crossplane.io_approvalrules.yaml
new file mode 100644
index 0000000..659e73b
--- /dev/null
+++ b/package/crds/projects.gitlab.crossplane.io_approvalrules.yaml
@@ -0,0 +1,434 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.17.3
+  name: approvalrules.projects.gitlab.crossplane.io
+spec:
+  group: projects.gitlab.crossplane.io
+  names:
+    categories:
+    - crossplane
+    - managed
+    - gitlab
+    kind: ApprovalRule
+    listKind: ApprovalRuleList
+    plural: approvalrules
+    singular: approvalrule
+  scope: Cluster
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .status.conditions[?(@.type=='Ready')].status
+      name: READY
+      type: string
+    - jsonPath: .status.conditions[?(@.type=='Synced')].status
+      name: SYNCED
+      type: string
+    - jsonPath: .metadata.creationTimestamp
+      name: AGE
+      type: date
+    - jsonPath: .spec.forProvider.projectId
+      name: Project ID
+      type: integer
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: A ApprovalRule is a managed resource that represents a Gitlab
+          Project ApprovalRule
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: A ApprovalRuleSpec defines the desired state of a Gitlab
+              Project Member.
+            properties:
+              deletionPolicy:
+                default: Delete
+                description: |-
+                  DeletionPolicy specifies what will happen to the underlying external
+                  when this managed resource is deleted - either "Delete" or "Orphan" the
+                  external resource.
+                  This field is planned to be deprecated in favor of the ManagementPolicies
+                  field in a future release. Currently, both could be set independently and
+                  non-default values would be honored if the feature flag is enabled.
+                  See the design doc for more information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223
+                enum:
+                - Orphan
+                - Delete
+                type: string
+              forProvider:
+                description: A ApprovalRuleParameters defines the desired state of
+                  a Gitlab Project Member.
+                properties:
+                  appliesToAllProtectedBranches:
+                    description: If true, applies the rule to all protected branches
+                      and ignores the protected_branch_ids attribute.
+                    type: boolean
+                  approvalsRequired:
+                    description: The number of required approvals for this rule.
+                    type: integer
+                  groupIds:
+                    description: The IDs of groups as approvers.
+                    items:
+                      type: integer
+                    type: array
+                  name:
+                    description: The name of the approval rule
+                    type: string
+                  projectId:
+                    description: The ID of the project owned by the authenticated
+                      user.
+                    type: integer
+                  projectIdRef:
+                    description: ProjectIDRef is a reference to a project to retrieve
+                      its projectId
+                    properties:
+                      name:
+                        description: Name of the referenced object.
+                        type: string
+                      policy:
+                        description: Policies for referencing.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: |-
+                              Resolution specifies whether resolution of this reference is required.
+                              The default is 'Required', which means the reconcile will fail if the
+                              reference cannot be resolved. 'Optional' means this reference will be
+                              a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: |-
+                              Resolve specifies when this reference should be resolved. The default
+                              is 'IfNotPresent', which will attempt to resolve the reference only when
+                              the corresponding field is not present. Use 'Always' to resolve the
+                              reference on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    required:
+                    - name
+                    type: object
+                  projectIdSelector:
+                    description: ProjectIDSelector selects reference to a project
+                      to retrieve its projectId.
+                    properties:
+                      matchControllerRef:
+                        description: |-
+                          MatchControllerRef ensures an object with the same controller reference
+                          as the selecting object is selected.
+                        type: boolean
+                      matchLabels:
+                        additionalProperties:
+                          type: string
+                        description: MatchLabels ensures an object with matching labels
+                          is selected.
+                        type: object
+                      policy:
+                        description: Policies for selection.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: |-
+                              Resolution specifies whether resolution of this reference is required.
+                              The default is 'Required', which means the reconcile will fail if the
+                              reference cannot be resolved. 'Optional' means this reference will be
+                              a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: |-
+                              Resolve specifies when this reference should be resolved. The default
+                              is 'IfNotPresent', which will attempt to resolve the reference only when
+                              the corresponding field is not present. Use 'Always' to resolve the
+                              reference on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    type: object
+                  protectedBranchIds:
+                    description: The IDs of protected branches to scope the rule by.
+                    items:
+                      type: integer
+                    type: array
+                  ruleType:
+                    description: The rule type. Supported values include any_approver,
+                      regular, and report_approver
+                    type: string
+                  userIds:
+                    description: The IDs of users as approvers. If used with usernames,
+                      adds both lists of users.
+                    items:
+                      type: integer
+                    type: array
+                  usernames:
+                    description: The usernames of approvers. If used with user_ids,
+                      adds both lists of users.
+                    items:
+                      type: string
+                    type: array
+                type: object
+              managementPolicies:
+                default:
+                - '*'
+                description: |-
+                  THIS IS A BETA FIELD. It is on by default but can be opted out
+                  through a Crossplane feature flag.
+                  ManagementPolicies specify the array of actions Crossplane is allowed to
+                  take on the managed and external resources.
+                  This field is planned to replace the DeletionPolicy field in a future
+                  release. Currently, both could be set independently and non-default
+                  values would be honored if the feature flag is enabled. If both are
+                  custom, the DeletionPolicy field will be ignored.
+                  See the design doc for more information: https://github.com/crossplane/crossplane/blob/499895a25d1a1a0ba1604944ef98ac7a1a71f197/design/design-doc-observe-only-resources.md?plain=1#L223
+                  and this one: https://github.com/crossplane/crossplane/blob/444267e84783136daa93568b364a5f01228cacbe/design/one-pager-ignore-changes.md
+                items:
+                  description: |-
+                    A ManagementAction represents an action that the Crossplane controllers
+                    can take on an external resource.
+                  enum:
+                  - Observe
+                  - Create
+                  - Update
+                  - Delete
+                  - LateInitialize
+                  - '*'
+                  type: string
+                type: array
+              providerConfigRef:
+                default:
+                  name: default
+                description: |-
+                  ProviderConfigReference specifies how the provider that will be used to
+                  create, observe, update, and delete this managed resource should be
+                  configured.
+                properties:
+                  name:
+                    description: Name of the referenced object.
+                    type: string
+                  policy:
+                    description: Policies for referencing.
+                    properties:
+                      resolution:
+                        default: Required
+                        description: |-
+                          Resolution specifies whether resolution of this reference is required.
+                          The default is 'Required', which means the reconcile will fail if the
+                          reference cannot be resolved. 'Optional' means this reference will be
+                          a no-op if it cannot be resolved.
+                        enum:
+                        - Required
+                        - Optional
+                        type: string
+                      resolve:
+                        description: |-
+                          Resolve specifies when this reference should be resolved. The default
+                          is 'IfNotPresent', which will attempt to resolve the reference only when
+                          the corresponding field is not present. Use 'Always' to resolve the
+                          reference on every reconcile.
+                        enum:
+                        - Always
+                        - IfNotPresent
+                        type: string
+                    type: object
+                required:
+                - name
+                type: object
+              publishConnectionDetailsTo:
+                description: |-
+                  PublishConnectionDetailsTo specifies the connection secret config which
+                  contains a name, metadata and a reference to secret store config to
+                  which any connection details for this managed resource should be written.
+                  Connection details frequently include the endpoint, username,
+                  and password required to connect to the managed resource.
+                properties:
+                  configRef:
+                    default:
+                      name: default
+                    description: |-
+                      SecretStoreConfigRef specifies which secret store config should be used
+                      for this ConnectionSecret.
+                    properties:
+                      name:
+                        description: Name of the referenced object.
+                        type: string
+                      policy:
+                        description: Policies for referencing.
+                        properties:
+                          resolution:
+                            default: Required
+                            description: |-
+                              Resolution specifies whether resolution of this reference is required.
+                              The default is 'Required', which means the reconcile will fail if the
+                              reference cannot be resolved. 'Optional' means this reference will be
+                              a no-op if it cannot be resolved.
+                            enum:
+                            - Required
+                            - Optional
+                            type: string
+                          resolve:
+                            description: |-
+                              Resolve specifies when this reference should be resolved. The default
+                              is 'IfNotPresent', which will attempt to resolve the reference only when
+                              the corresponding field is not present. Use 'Always' to resolve the
+                              reference on every reconcile.
+                            enum:
+                            - Always
+                            - IfNotPresent
+                            type: string
+                        type: object
+                    required:
+                    - name
+                    type: object
+                  metadata:
+                    description: Metadata is the metadata for connection secret.
+                    properties:
+                      annotations:
+                        additionalProperties:
+                          type: string
+                        description: |-
+                          Annotations are the annotations to be added to connection secret.
+                          - For Kubernetes secrets, this will be used as "metadata.annotations".
+                          - It is up to Secret Store implementation for others store types.
+                        type: object
+                      labels:
+                        additionalProperties:
+                          type: string
+                        description: |-
+                          Labels are the labels/tags to be added to connection secret.
+                          - For Kubernetes secrets, this will be used as "metadata.labels".
+                          - It is up to Secret Store implementation for others store types.
+                        type: object
+                      type:
+                        description: |-
+                          Type is the SecretType for the connection secret.
+                          - Only valid for Kubernetes Secret Stores.
+                        type: string
+                    type: object
+                  name:
+                    description: Name is the name of the connection secret.
+                    type: string
+                required:
+                - name
+                type: object
+              writeConnectionSecretToRef:
+                description: |-
+                  WriteConnectionSecretToReference specifies the namespace and name of a
+                  Secret to which any connection details for this managed resource should
+                  be written. Connection details frequently include the endpoint, username,
+                  and password required to connect to the managed resource.
+                  This field is planned to be replaced in a future release in favor of
+                  PublishConnectionDetailsTo. Currently, both could be set independently
+                  and connection details would be published to both without affecting
+                  each other.
+                properties:
+                  name:
+                    description: Name of the secret.
+                    type: string
+                  namespace:
+                    description: Namespace of the secret.
+                    type: string
+                required:
+                - name
+                - namespace
+                type: object
+            required:
+            - forProvider
+            type: object
+          status:
+            description: A ApprovalRuleStatus represents the observed state of a Gitlab
+              Project Member.
+            properties:
+              atProvider:
+                description: |-
+                  ApprovalRuleObservation represents a project member.
+
+                  GitLab API docs:
+                  https://docs.gitlab.com/ce/api/projects.html#list-project-team-members
+                type: object
+              conditions:
+                description: Conditions of the resource.
+                items:
+                  description: A Condition that may apply to a resource.
+                  properties:
+                    lastTransitionTime:
+                      description: |-
+                        LastTransitionTime is the last time this condition transitioned from one
+                        status to another.
+                      format: date-time
+                      type: string
+                    message:
+                      description: |-
+                        A Message containing details about this condition's last transition from
+                        one status to another, if any.
+                      type: string
+                    observedGeneration:
+                      description: |-
+                        ObservedGeneration represents the .metadata.generation that the condition was set based upon.
+                        For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+                        with respect to the current state of the instance.
+                      format: int64
+                      type: integer
+                    reason:
+                      description: A Reason for this condition's last transition from
+                        one status to another.
+                      type: string
+                    status:
+                      description: Status of this condition; is it currently True,
+                        False, or Unknown?
+                      type: string
+                    type:
+                      description: |-
+                        Type of this condition. At most one of each condition type may apply to
+                        a resource at any point in time.
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              observedGeneration:
+                description: |-
+                  ObservedGeneration is the latest metadata.generation
+                  which resulted in either a ready state, or stalled due to error
+                  it can not recover from without human intervention.
+                format: int64
+                type: integer
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
diff --git a/package/crds/projects.gitlab.crossplane.io_projects.yaml b/package/crds/projects.gitlab.crossplane.io_projects.yaml
index 20f2bfc..5cb4960 100644
--- a/package/crds/projects.gitlab.crossplane.io_projects.yaml
+++ b/package/crds/projects.gitlab.crossplane.io_projects.yaml
@@ -82,6 +82,7 @@ spec:
                     description: |-
                       How many approvers should approve merge request by default.More actions
                       To configure approval rules, see Merge request approvals API.
+                      Deprecated: use Approvals Resource instead
                     type: integer
                   autoCancelPendingPipelines:
                     description: Auto-cancel pending pipelines. This isn’t a boolean,
diff --git a/pkg/clients/projects/approvalrules.go b/pkg/clients/projects/approvalrules.go
new file mode 100644
index 0000000..f63e013
--- /dev/null
+++ b/pkg/clients/projects/approvalrules.go
@@ -0,0 +1,251 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+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.
+*/
+
+package projects
+
+import (
+	"github.com/google/go-cmp/cmp"
+	"gitlab.com/gitlab-org/api/client-go"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/projects/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients"
+)
+
+// ApprovalRulesClient Gitlab Member service operations
+type ApprovalRulesClient interface {
+	GetProjectApprovalRule(pid any, ruleID int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error)
+	CreateProjectApprovalRule(pid any, opt *gitlab.CreateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error)
+	UpdateProjectApprovalRule(pid any, approvalRule int, opt *gitlab.UpdateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error)
+	DeleteProjectApprovalRule(pid any, approvalRule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+}
+
+// NewApprovalRulesClient returns a new Gitlab Project Member service
+func NewApprovalRulesClient(cfg clients.Config) ApprovalRulesClient {
+	git := clients.NewClient(cfg)
+	return git.Projects
+}
+
+// GenerateCreateApprovalRulesOptions generates project member add options
+func GenerateCreateApprovalRulesOptions(p *v1alpha1.ApprovalRuleParameters) *gitlab.CreateProjectLevelRuleOptions {
+	approvalRulesOptions := &gitlab.CreateProjectLevelRuleOptions{
+		Name:                          p.Name,
+		ApprovalsRequired:             p.ApprovalsRequired,
+		RuleType:                      (*string)(p.RuleType),
+		AppliesToAllProtectedBranches: p.AppliesToAllProtectedBranches,
+		UserIDs:                       p.UserIDs,
+		GroupIDs:                      p.GroupIDs,
+		ProtectedBranchIDs:            p.ProtectedBranchIDs,
+		Usernames:                     p.Usernames,
+	}
+
+	return approvalRulesOptions
+}
+
+// GenerateUpdateApprovalRulesOptions generates project member edit options
+func GenerateUpdateApprovalRulesOptions(p *v1alpha1.ApprovalRuleParameters) *gitlab.UpdateProjectLevelRuleOptions {
+	approvalRulesOptions := &gitlab.UpdateProjectLevelRuleOptions{
+		Name:                          p.Name,
+		ApprovalsRequired:             p.ApprovalsRequired,
+		AppliesToAllProtectedBranches: p.AppliesToAllProtectedBranches,
+		UserIDs:                       p.UserIDs,
+		GroupIDs:                      p.GroupIDs,
+		ProtectedBranchIDs:            p.ProtectedBranchIDs,
+		Usernames:                     p.Usernames,
+	}
+
+	return approvalRulesOptions
+}
+
+// IsApprovalRuleUpToDate checks whether there is a change in any of the modifiable fields.
+func IsApprovalRuleUpToDate(p *v1alpha1.ApprovalRuleParameters, g *gitlab.ProjectApprovalRule) bool {
+	if !cmp.Equal(p.Name, clients.StringToPtr(g.Name)) {
+		return false
+	}
+
+	if !clients.IsBoolEqualToBoolPtr(p.AppliesToAllProtectedBranches, g.AppliesToAllProtectedBranches) {
+		return false
+	}
+
+	if !clients.IsIntEqualToIntPtr(p.ApprovalsRequired, g.ApprovalsRequired) {
+		return false
+	}
+
+	if !clients.IsStringEqualToStringPtr((*string)(p.RuleType), g.RuleType) {
+		return false
+	}
+
+	if !isGroupIDsUpToDate(p, g) {
+		return false
+	}
+
+	if !isProtectedBranchesIDsUpToDate(p, g) {
+		return false
+	}
+
+	if !isUserIDsUpToDate(p, g) {
+		return false
+	}
+
+	if !isUsernamesUpToDate(p, g) {
+		return false
+	}
+
+	return true
+}
+
+func isGroupIDsUpToDate(cr *v1alpha1.ApprovalRuleParameters, in *gitlab.ProjectApprovalRule) bool {
+	if cr.GroupIDs == nil {
+		return len(in.Groups) == 0
+	}
+
+	if len(*cr.GroupIDs) != len(in.Groups) {
+		return false
+	}
+
+	inIDs := make(map[int]any)
+	for _, v := range in.Groups {
+		inIDs[v.ID] = nil
+	}
+
+	crIDs := make(map[int]any)
+	for _, v := range *cr.GroupIDs {
+		crIDs[v] = nil
+	}
+
+	for ID := range inIDs {
+		_, ok := crIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	for ID := range crIDs {
+		_, ok := inIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	return true
+}
+
+func isProtectedBranchesIDsUpToDate(cr *v1alpha1.ApprovalRuleParameters, in *gitlab.ProjectApprovalRule) bool {
+	if cr.ProtectedBranchIDs == nil {
+		return len(in.ProtectedBranches) == 0
+	}
+
+	if len(*cr.ProtectedBranchIDs) != len(in.ProtectedBranches) {
+		return false
+	}
+
+	inIDs := make(map[int]any)
+	for _, v := range in.ProtectedBranches {
+		inIDs[v.ID] = nil
+	}
+
+	crIDs := make(map[int]any)
+	for _, v := range *cr.ProtectedBranchIDs {
+		crIDs[v] = nil
+	}
+
+	for ID := range inIDs {
+		_, ok := crIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	for ID := range crIDs {
+		_, ok := inIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	return true
+}
+
+func isUserIDsUpToDate(cr *v1alpha1.ApprovalRuleParameters, in *gitlab.ProjectApprovalRule) bool {
+	if cr.UserIDs == nil {
+		return len(in.Users) == 0
+	}
+
+	if len(*cr.UserIDs) != len(in.Users) {
+		return false
+	}
+
+	inIDs := make(map[int]any)
+	for _, v := range in.Users {
+		inIDs[v.ID] = nil
+	}
+
+	crIDs := make(map[int]any)
+	for _, v := range *cr.UserIDs {
+		crIDs[v] = nil
+	}
+
+	for ID := range inIDs {
+		_, ok := crIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	for ID := range crIDs {
+		_, ok := inIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	return true
+}
+
+func isUsernamesUpToDate(cr *v1alpha1.ApprovalRuleParameters, in *gitlab.ProjectApprovalRule) bool {
+	if cr.Usernames == nil {
+		return len(in.Users) == 0
+	}
+
+	if len(*cr.Usernames) != len(in.Users) {
+		return false
+	}
+
+	inIDs := make(map[string]any)
+	for _, v := range in.Users {
+		inIDs[v.Username] = nil
+	}
+
+	crIDs := make(map[string]any)
+	for _, v := range *cr.Usernames {
+		crIDs[v] = nil
+	}
+
+	for ID := range inIDs {
+		_, ok := crIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	for ID := range crIDs {
+		_, ok := inIDs[ID]
+		if !ok {
+			return false
+		}
+	}
+
+	return true
+}
diff --git a/pkg/clients/projects/fake/fake.go b/pkg/clients/projects/fake/fake.go
index ce5c95c..e7d4fee 100644
--- a/pkg/clients/projects/fake/fake.go
+++ b/pkg/clients/projects/fake/fake.go
@@ -28,91 +28,96 @@ var _ projects.Client = &MockClient{}
 type MockClient struct {
 	projects.Client
 
-	MockGetProject    func(pid interface{}, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
+	MockGetProject    func(pid any, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
 	MockCreateProject func(opt *gitlab.CreateProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
-	MockEditProject   func(pid interface{}, opt *gitlab.EditProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
-	MockDeleteProject func(pid interface{}, opt *gitlab.DeleteProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-
-	MockGetHook    func(pid interface{}, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error)
-	MockAddHook    func(pid interface{}, opt *gitlab.AddProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error)
-	MockEditHook   func(pid interface{}, hook int, opt *gitlab.EditProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error)
-	MockDeleteHook func(pid interface{}, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-
-	MockGetMember    func(pid interface{}, user int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error)
-	MockAddMember    func(pid interface{}, opt *gitlab.AddProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error)
-	MockEditMember   func(pid interface{}, user int, opt *gitlab.EditProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error)
-	MockDeleteMember func(pid interface{}, user int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-
-	MockCreateDeployToken     func(pid interface{}, opt *gitlab.CreateProjectDeployTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
-	MockDeleteDeployToken     func(pid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-	MockGetProjectDeployToken func(pid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
-
-	MockGetVariable    func(pid interface{}, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
-	MockCreateVariable func(pid interface{}, opt *gitlab.CreateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
-	MockUpdateVariable func(pid interface{}, key string, opt *gitlab.UpdateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
-	MockListVariables  func(pid interface{}, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error)
-	MockRemoveVariable func(pid interface{}, key string, opt *gitlab.RemoveProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-
-	MockGetProjectAccessToken    func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error)
-	MockCreateProjectAccessToken func(pid interface{}, opt *gitlab.CreateProjectAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error)
-	MockRevokeProjectAccessToken func(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-
-	MockAddDeployKey    func(pid interface{}, opt *gitlab.AddDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error)
-	MockDeleteDeployKey func(pid interface{}, deployKey int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-	MockUpdateDeployKey func(pid interface{}, deployKey int, opt *gitlab.UpdateDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error)
-	MockGetDeployKey    func(pid interface{}, deployKey int, options ...*gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error)
-
-	MockGetPipelineSchedule            func(pid interface{}, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error)
-	MockCreatePipelineSchedule         func(pid interface{}, opt *gitlab.CreatePipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error)
-	MockEditPipelineSchedule           func(pid interface{}, schedule int, opt *gitlab.EditPipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error)
-	MockDeletePipelineSchedule         func(pid interface{}, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
-	MockCreatePipelineScheduleVariable func(pid interface{}, schedule int, opt *gitlab.CreatePipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error)
-	MockEditPipelineScheduleVariable   func(pid interface{}, schedule int, key string, opt *gitlab.EditPipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error)
-	MockDeletePipelineScheduleVariable func(pid interface{}, schedule int, key string, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error)
+	MockEditProject   func(pid any, opt *gitlab.EditProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
+	MockDeleteProject func(pid any, opt *gitlab.DeleteProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockGetHook    func(pid any, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error)
+	MockAddHook    func(pid any, opt *gitlab.AddProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error)
+	MockEditHook   func(pid any, hook int, opt *gitlab.EditProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error)
+	MockDeleteHook func(pid any, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockGetMember    func(pid any, user int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error)
+	MockAddMember    func(pid any, opt *gitlab.AddProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error)
+	MockEditMember   func(pid any, user int, opt *gitlab.EditProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error)
+	MockDeleteMember func(pid any, user int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockCreateDeployToken     func(pid any, opt *gitlab.CreateProjectDeployTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
+	MockDeleteDeployToken     func(pid any, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+	MockGetProjectDeployToken func(pid any, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error)
+
+	MockGetVariable    func(pid any, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
+	MockCreateVariable func(pid any, opt *gitlab.CreateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
+	MockUpdateVariable func(pid any, key string, opt *gitlab.UpdateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error)
+	MockListVariables  func(pid any, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error)
+	MockRemoveVariable func(pid any, key string, opt *gitlab.RemoveProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockGetProjectAccessToken    func(pid any, id int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error)
+	MockCreateProjectAccessToken func(pid any, opt *gitlab.CreateProjectAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error)
+	MockRevokeProjectAccessToken func(pid any, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+
+	MockAddDeployKey    func(pid any, opt *gitlab.AddDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error)
+	MockDeleteDeployKey func(pid any, deployKey int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+	MockUpdateDeployKey func(pid any, deployKey int, opt *gitlab.UpdateDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error)
+	MockGetDeployKey    func(pid any, deployKey int, options ...*gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error)
+
+	MockGetPipelineSchedule            func(pid any, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error)
+	MockCreatePipelineSchedule         func(pid any, opt *gitlab.CreatePipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error)
+	MockEditPipelineSchedule           func(pid any, schedule int, opt *gitlab.EditPipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error)
+	MockDeletePipelineSchedule         func(pid any, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
+	MockCreatePipelineScheduleVariable func(pid any, schedule int, opt *gitlab.CreatePipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error)
+	MockEditPipelineScheduleVariable   func(pid any, schedule int, key string, opt *gitlab.EditPipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error)
+	MockDeletePipelineScheduleVariable func(pid any, schedule int, key string, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error)
 
 	MockListUsers func(opt *gitlab.ListUsersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.User, *gitlab.Response, error)
 
-	MockGetProjectPushRules func(pid interface{}, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error)
-	MockEditProjectPushRule func(pid interface{}, opt *gitlab.EditProjectPushRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error)
+	MockGetProjectPushRules func(pid any, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error)
+	MockEditProjectPushRule func(pid any, opt *gitlab.EditProjectPushRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error)
+
+	MockGetProjectApprovalRule    func(pid any, ruleID int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error)
+	MockCreateProjectApprovalRule func(pid any, opt *gitlab.CreateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error)
+	MockUpdateProjectApprovalRule func(pid any, approvalRule int, opt *gitlab.UpdateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error)
+	MockDeleteProjectApprovalRule func(pid any, approvalRule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
 }
 
 // GetPipelineSchedule calls the underlying MockGetPipelineSchedule method.
-func (c *MockClient) GetPipelineSchedule(pid interface{}, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error) {
+func (c *MockClient) GetPipelineSchedule(pid any, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error) {
 	return c.MockGetPipelineSchedule(pid, schedule, options...)
 }
 
 // CreatePipelineSchedule calls the underlying MockCreatePipelineSchedule method.
-func (c *MockClient) CreatePipelineSchedule(pid interface{}, opt *gitlab.CreatePipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error) {
+func (c *MockClient) CreatePipelineSchedule(pid any, opt *gitlab.CreatePipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error) {
 	return c.MockCreatePipelineSchedule(pid, opt)
 }
 
 // EditPipelineSchedule calls the underlying MockEditPipelineSchedule method.
-func (c *MockClient) EditPipelineSchedule(pid interface{}, schedule int, opt *gitlab.EditPipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error) {
+func (c *MockClient) EditPipelineSchedule(pid any, schedule int, opt *gitlab.EditPipelineScheduleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineSchedule, *gitlab.Response, error) {
 	return c.MockEditPipelineSchedule(pid, schedule, opt)
 }
 
 // DeletePipelineSchedule calls the underlying MockDeletePipelineSchedule method.
-func (c *MockClient) DeletePipelineSchedule(pid interface{}, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) DeletePipelineSchedule(pid any, schedule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeletePipelineSchedule(pid, schedule)
 }
 
 // CreatePipelineScheduleVariable calls the underlying MockCreatePipelineScheduleVariable method.
-func (c *MockClient) CreatePipelineScheduleVariable(pid interface{}, schedule int, opt *gitlab.CreatePipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error) {
+func (c *MockClient) CreatePipelineScheduleVariable(pid any, schedule int, opt *gitlab.CreatePipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error) {
 	return c.MockCreatePipelineScheduleVariable(pid, schedule, opt)
 }
 
 // EditPipelineScheduleVariable calls the underlying MockEditPipelineScheduleVariable method.
-func (c *MockClient) EditPipelineScheduleVariable(pid interface{}, schedule int, key string, opt *gitlab.EditPipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error) {
+func (c *MockClient) EditPipelineScheduleVariable(pid any, schedule int, key string, opt *gitlab.EditPipelineScheduleVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error) {
 	return c.MockEditPipelineScheduleVariable(pid, schedule, key, opt)
 }
 
 // DeletePipelineScheduleVariable calls the underlying MockDeletePipelineScheduleVariable method.
-func (c *MockClient) DeletePipelineScheduleVariable(pid interface{}, schedule int, key string, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error) {
+func (c *MockClient) DeletePipelineScheduleVariable(pid any, schedule int, key string, options ...gitlab.RequestOptionFunc) (*gitlab.PipelineVariable, *gitlab.Response, error) {
 	return c.MockDeletePipelineScheduleVariable(pid, schedule, key)
 }
 
 // GetProject calls the underlying MockGetProject method.
-func (c *MockClient) GetProject(pid interface{}, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) {
+func (c *MockClient) GetProject(pid any, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) {
 	return c.MockGetProject(pid, opt)
 }
 
@@ -122,132 +127,132 @@ func (c *MockClient) CreateProject(opt *gitlab.CreateProjectOptions, options ...
 }
 
 // EditProject calls the underlying MockEditProject method
-func (c *MockClient) EditProject(pid interface{}, opt *gitlab.EditProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) {
+func (c *MockClient) EditProject(pid any, opt *gitlab.EditProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) {
 	return c.MockEditProject(pid, opt)
 }
 
 // DeleteProject calls the underlying MockDeleteProject method
-func (c *MockClient) DeleteProject(pid interface{}, opt *gitlab.DeleteProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) DeleteProject(pid any, opt *gitlab.DeleteProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeleteProject(pid, opt)
 }
 
 // GetProjectHook calls the underlying MockGetProjectHook method.
-func (c *MockClient) GetProjectHook(pid interface{}, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error) {
+func (c *MockClient) GetProjectHook(pid any, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error) {
 	return c.MockGetHook(pid, hook)
 }
 
 // AddProjectHook calls the underlying MockAddHook method.
 // AddProjectHook calls the underlying MockAddHook method.
-func (c *MockClient) AddProjectHook(pid interface{}, opt *gitlab.AddProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error) {
+func (c *MockClient) AddProjectHook(pid any, opt *gitlab.AddProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error) {
 	return c.MockAddHook(pid, opt)
 }
 
 // EditProjectHook calls the underlying MockEditProjectHook method.
-func (c *MockClient) EditProjectHook(pid interface{}, hook int, opt *gitlab.EditProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error) {
+func (c *MockClient) EditProjectHook(pid any, hook int, opt *gitlab.EditProjectHookOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectHook, *gitlab.Response, error) {
 	return c.MockEditHook(pid, hook, opt)
 }
 
 // DeleteProjectHook calls the underlying MockDeleteProjectHook method.
-func (c *MockClient) DeleteProjectHook(pid interface{}, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) DeleteProjectHook(pid any, hook int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeleteHook(pid, hook)
 }
 
 // GetProjectMember calls the underlying MockGetMember method.
 // GetProjectMember calls the underlying MockGetMember method.
-func (c *MockClient) GetProjectMember(pid interface{}, user int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error) {
+func (c *MockClient) GetProjectMember(pid any, user int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error) {
 	return c.MockGetMember(pid, user)
 }
 
 // AddProjectMember calls the underlying MockAddMember method.
 // AddProjectMember calls the underlying MockAddMember method.
-func (c *MockClient) AddProjectMember(pid interface{}, opt *gitlab.AddProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error) {
+func (c *MockClient) AddProjectMember(pid any, opt *gitlab.AddProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error) {
 	return c.MockAddMember(pid, opt)
 }
 
 // EditProjectMember calls the underlying MockEditMember method.
 // EditProjectMember calls the underlying MockEditMember method.
-func (c *MockClient) EditProjectMember(pid interface{}, user int, opt *gitlab.EditProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error) {
+func (c *MockClient) EditProjectMember(pid any, user int, opt *gitlab.EditProjectMemberOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectMember, *gitlab.Response, error) {
 	return c.MockEditMember(pid, user, opt)
 }
 
 // DeleteProjectMember calls the underlying MockDeleteMember method.
 // DeleteProjectMember calls the underlying MockDeleteMember method.
-func (c *MockClient) DeleteProjectMember(pid interface{}, user int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) DeleteProjectMember(pid any, user int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeleteMember(pid, user)
 }
 
 // CreateProjectDeployToken calls the underlying MockCreateProjectDeployToken method.
-func (c *MockClient) CreateProjectDeployToken(pid interface{}, opt *gitlab.CreateProjectDeployTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error) {
+func (c *MockClient) CreateProjectDeployToken(pid any, opt *gitlab.CreateProjectDeployTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error) {
 	return c.MockCreateDeployToken(pid, opt)
 }
 
 // DeleteProjectDeployToken calls the underlying MockDeleteProjectDeployToken method.
-func (c *MockClient) DeleteProjectDeployToken(pid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) DeleteProjectDeployToken(pid any, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeleteDeployToken(pid, deployToken)
 }
 
 // GetProjectDeployToken calls the underlying MockGetProjectDeployToken method.
-func (c *MockClient) GetProjectDeployToken(pid interface{}, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error) {
+func (c *MockClient) GetProjectDeployToken(pid any, deployToken int, options ...gitlab.RequestOptionFunc) (*gitlab.DeployToken, *gitlab.Response, error) {
 	return c.MockGetProjectDeployToken(pid, deployToken)
 }
 
 // GetVariable calls the underlying MockGetProjectVariable
-func (c *MockClient) GetVariable(pid interface{}, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
+func (c *MockClient) GetVariable(pid any, key string, opt *gitlab.GetProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
 	return c.MockGetVariable(pid, key, opt)
 }
 
 // CreateVariable calls the underlying MockCreateProjectVariable
-func (c *MockClient) CreateVariable(pid interface{}, opt *gitlab.CreateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
+func (c *MockClient) CreateVariable(pid any, opt *gitlab.CreateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
 	return c.MockCreateVariable(pid, opt)
 }
 
 // UpdateVariable calls the underlying MockUpdateProjectVariable
-func (c *MockClient) UpdateVariable(pid interface{}, key string, opt *gitlab.UpdateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
+func (c *MockClient) UpdateVariable(pid any, key string, opt *gitlab.UpdateProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectVariable, *gitlab.Response, error) {
 	return c.MockUpdateVariable(pid, key, opt)
 }
 
 // RemoveVariable calls the underlying MockRemoveProjectVariable
-func (c *MockClient) RemoveVariable(pid interface{}, key string, opt *gitlab.RemoveProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) RemoveVariable(pid any, key string, opt *gitlab.RemoveProjectVariableOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockRemoveVariable(pid, key, opt)
 }
 
 // ListVariables calls the underlying MockListVariables
-func (c *MockClient) ListVariables(pid interface{}, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error) {
+func (c *MockClient) ListVariables(pid any, opt *gitlab.ListProjectVariablesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectVariable, *gitlab.Response, error) {
 	return c.MockListVariables(pid, opt)
 }
 
 // GetDeployKey calls the underlying MockGetDeployKey
-func (c *MockClient) GetDeployKey(pid interface{}, deployKey int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) {
+func (c *MockClient) GetDeployKey(pid any, deployKey int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) {
 	return c.MockGetDeployKey(pid, deployKey)
 }
 
 // AddDeployKey calls the underlying MockAddDeployKey
-func (c *MockClient) AddDeployKey(pid interface{}, opt *gitlab.AddDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) {
+func (c *MockClient) AddDeployKey(pid any, opt *gitlab.AddDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) {
 	return c.MockAddDeployKey(pid, opt)
 }
 
 // DeleteDeployKey calls the underlying MockDeleteDeployKey
-func (c *MockClient) DeleteDeployKey(pid interface{}, deployKey int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) DeleteDeployKey(pid any, deployKey int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockDeleteDeployKey(pid, deployKey)
 }
 
 // UpdateDeployKey cals the underlying MockUpdateDeployKey
-func (c *MockClient) UpdateDeployKey(pid interface{}, deployKey int, opt *gitlab.UpdateDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) {
+func (c *MockClient) UpdateDeployKey(pid any, deployKey int, opt *gitlab.UpdateDeployKeyOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectDeployKey, *gitlab.Response, error) {
 	return c.MockUpdateDeployKey(pid, deployKey, opt)
 }
 
 // GetProjectAccessToken calls the underlying MockGetProjectAccessToken method.
-func (c *MockClient) GetProjectAccessToken(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) {
+func (c *MockClient) GetProjectAccessToken(pid any, id int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) {
 	return c.MockGetProjectAccessToken(pid, id)
 }
 
 // CreateProjectAccessToken calls the underlying MockCreateProjectAccessToken method.
-func (c *MockClient) CreateProjectAccessToken(pid interface{}, opt *gitlab.CreateProjectAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) {
+func (c *MockClient) CreateProjectAccessToken(pid any, opt *gitlab.CreateProjectAccessTokenOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectAccessToken, *gitlab.Response, error) {
 	return c.MockCreateProjectAccessToken(pid, opt)
 }
 
 // RevokeProjectAccessToken calls the underlying MockRevokeProjectAccessToken method.
-func (c *MockClient) RevokeProjectAccessToken(pid interface{}, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+func (c *MockClient) RevokeProjectAccessToken(pid any, id int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
 	return c.MockRevokeProjectAccessToken(pid, id)
 }
 
@@ -257,11 +262,27 @@ func (c *MockClient) ListUsers(opt *gitlab.ListUsersOptions, options ...gitlab.R
 }
 
 // GetProjectPushRules calls the underlying MockGetProjectPushRules method.
-func (c *MockClient) GetProjectPushRules(pid interface{}, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error) {
+func (c *MockClient) GetProjectPushRules(pid any, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error) {
 	return c.MockGetProjectPushRules(pid, options...)
 }
 
 // EditProjectPushRule calls the underlying MockEditProjectPushRule method.
-func (c *MockClient) EditProjectPushRule(pid interface{}, opt *gitlab.EditProjectPushRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error) {
+func (c *MockClient) EditProjectPushRule(pid any, opt *gitlab.EditProjectPushRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectPushRules, *gitlab.Response, error) {
 	return c.MockEditProjectPushRule(pid, opt, options...)
 }
+
+func (c *MockClient) GetProjectApprovalRule(pid any, ruleID int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+	return c.MockGetProjectApprovalRule(pid, ruleID, options...)
+}
+
+func (c *MockClient) CreateProjectApprovalRule(pid any, opt *gitlab.CreateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+	return c.MockCreateProjectApprovalRule(pid, opt, options...)
+}
+
+func (c *MockClient) UpdateProjectApprovalRule(pid any, approvalRule int, opt *gitlab.UpdateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+	return c.MockUpdateProjectApprovalRule(pid, approvalRule, opt, options...)
+}
+
+func (c *MockClient) DeleteProjectApprovalRule(pid any, approvalRule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+	return c.MockDeleteProjectApprovalRule(pid, approvalRule, options...)
+}
diff --git a/pkg/controller/projects/approvalrules/controller.go b/pkg/controller/projects/approvalrules/controller.go
new file mode 100644
index 0000000..32d7677
--- /dev/null
+++ b/pkg/controller/projects/approvalrules/controller.go
@@ -0,0 +1,237 @@
+/*
+Copyright 2021 The Crossplane Authors.
+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.
+*/
+
+package approvalrules
+
+import (
+	"context"
+	"strconv"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/connection"
+	"github.com/crossplane/crossplane-runtime/pkg/controller"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/feature"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+	"github.com/crossplane/crossplane-runtime/pkg/statemetrics"
+	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
+	gitlab "gitlab.com/gitlab-org/api/client-go"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/projects/v1alpha1"
+	secretstoreapi "github.com/crossplane-contrib/provider-gitlab/apis/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/projects"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/features"
+)
+
+const (
+	errNotApprovalRule  = "managed resource is not a Gitlab Project Approval Rule custom resource"
+	errCreateFailed     = "cannot create Gitlab Approval Rule"
+	errUpdateFailed     = "cannot update Gitlab Approval Rule"
+	errDeleteFailed     = "cannot delete Gitlab Approval Rule"
+	errObserveFailed    = "cannot observe Gitlab Approval Rule"
+	errProjectIDMissing = "ProjectID is missing"
+	errIDnotInt         = "ID is not an integer"
+)
+
+// SetupRules adds a controller that reconciles Approval Rules.
+func SetupRules(mgr ctrl.Manager, o controller.Options) error {
+	name := managed.ControllerName(v1alpha1.ApprovalRuleKind)
+
+	cps := []managed.ConnectionPublisher{managed.NewAPISecretPublisher(mgr.GetClient(), mgr.GetScheme())}
+	if o.Features.Enabled(features.EnableAlphaExternalSecretStores) {
+		cps = append(cps, connection.NewDetailsManager(mgr.GetClient(), secretstoreapi.StoreConfigGroupVersionKind))
+	}
+
+	reconcilerOpts := []managed.ReconcilerOption{
+		managed.WithExternalConnecter(&connector{
+			kube:              mgr.GetClient(),
+			newGitlabClientFn: projects.NewApprovalRulesClient,
+		}),
+		managed.WithInitializers(),
+		managed.WithPollInterval(o.PollInterval),
+		managed.WithLogger(o.Logger.WithValues("controller", name)),
+		managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))),
+		managed.WithConnectionPublishers(cps...),
+	}
+
+	if o.Features.Enabled(feature.EnableBetaManagementPolicies) {
+		reconcilerOpts = append(reconcilerOpts, managed.WithManagementPolicies())
+	}
+
+	r := managed.NewReconciler(mgr,
+		resource.ManagedKind(v1alpha1.ApprovalRuleGroupVersionKind),
+		reconcilerOpts...)
+
+	if err := mgr.Add(statemetrics.NewMRStateRecorder(
+		mgr.GetClient(), o.Logger, o.MetricOptions.MRStateMetrics, &v1alpha1.ApprovalRuleList{}, o.MetricOptions.PollStateMetricInterval)); err != nil {
+		return err
+	}
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v1alpha1.ApprovalRule{}).
+		Complete(r)
+}
+
+type connector struct {
+	kube              client.Client
+	newGitlabClientFn func(cfg clients.Config) projects.ApprovalRulesClient
+}
+
+func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) {
+	cr, ok := mg.(*v1alpha1.ApprovalRule)
+	if !ok {
+		return nil, errors.New(errNotApprovalRule)
+	}
+	cfg, err := clients.GetConfig(ctx, c.kube, cr)
+	if err != nil {
+		return nil, err
+	}
+	return &external{kube: c.kube, client: c.newGitlabClientFn(*cfg)}, nil
+}
+
+type external struct {
+	kube   client.Client
+	client projects.ApprovalRulesClient
+}
+
+func (e *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
+	cr, ok := mg.(*v1alpha1.ApprovalRule)
+	if !ok {
+		return managed.ExternalObservation{}, errors.New(errNotApprovalRule)
+	}
+
+	externalName := meta.GetExternalName(cr)
+	if externalName == "" {
+		return managed.ExternalObservation{ResourceExists: false}, nil
+	}
+
+	id, err := strconv.Atoi(externalName)
+	if err != nil {
+		return managed.ExternalObservation{}, errors.New(errIDnotInt)
+	}
+
+	if cr.Spec.ForProvider.ProjectID == nil {
+		return managed.ExternalObservation{}, errors.New(errProjectIDMissing)
+	}
+
+	approvalRule, res, err := e.client.GetProjectApprovalRule(*cr.Spec.ForProvider.ProjectID, id)
+	if err != nil {
+		if clients.IsResponseNotFound(res) {
+			return managed.ExternalObservation{ResourceExists: false}, nil
+		}
+
+		return managed.ExternalObservation{}, errors.Wrap(err, errObserveFailed)
+	}
+
+	current := cr.Spec.ForProvider.DeepCopy()
+
+	cr.Status.AtProvider = v1alpha1.ApprovalRuleObservation{}
+	cr.Status.SetConditions(xpv1.Available())
+
+	return managed.ExternalObservation{
+		ResourceExists:          true,
+		ResourceUpToDate:        projects.IsApprovalRuleUpToDate(&cr.Spec.ForProvider, approvalRule),
+		ResourceLateInitialized: !cmp.Equal(current, &cr.Spec.ForProvider),
+	}, nil
+
+}
+
+func (e *external) Create(ctx context.Context, mg resource.Managed) (managed.ExternalCreation, error) {
+	cr, ok := mg.(*v1alpha1.ApprovalRule)
+
+	if !ok {
+		return managed.ExternalCreation{}, errors.New(errNotApprovalRule)
+	}
+
+	if cr.Spec.ForProvider.ProjectID == nil {
+		return managed.ExternalCreation{}, errors.New(errProjectIDMissing)
+	}
+
+	cr.Status.SetConditions(xpv1.Creating())
+	approvalRulesOptions := projects.GenerateCreateApprovalRulesOptions(&cr.Spec.ForProvider)
+
+	rule, _, err := e.client.CreateProjectApprovalRule(*cr.Spec.ForProvider.ProjectID, approvalRulesOptions, gitlab.WithContext(ctx))
+
+	if err != nil {
+		return managed.ExternalCreation{}, errors.Wrap(err, errCreateFailed)
+	}
+
+	err = e.updateExternalName(ctx, cr, rule)
+	return managed.ExternalCreation{}, err
+}
+
+func (e *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) {
+	cr, ok := mg.(*v1alpha1.ApprovalRule)
+	if !ok {
+		return managed.ExternalUpdate{}, errors.New(errNotApprovalRule)
+	}
+	if cr.Spec.ForProvider.ProjectID == nil {
+		return managed.ExternalUpdate{}, errors.New(errProjectIDMissing)
+	}
+
+	ruleID, err := strconv.Atoi(meta.GetExternalName(cr))
+
+	if err != nil {
+		return managed.ExternalUpdate{}, errors.New(errIDnotInt)
+	}
+
+	_, _, err = e.client.UpdateProjectApprovalRule(
+		*cr.Spec.ForProvider.ProjectID,
+		ruleID,
+		projects.GenerateUpdateApprovalRulesOptions(&cr.Spec.ForProvider),
+		gitlab.WithContext(ctx),
+	)
+
+	return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateFailed)
+}
+
+func (e *external) Delete(ctx context.Context, mg resource.Managed) (managed.ExternalDelete, error) {
+	cr, ok := mg.(*v1alpha1.ApprovalRule)
+	if !ok {
+		return managed.ExternalDelete{}, errors.New(errNotApprovalRule)
+	}
+	if cr.Spec.ForProvider.ProjectID == nil {
+		return managed.ExternalDelete{}, errors.New(errProjectIDMissing)
+	}
+
+	ruleID, err := strconv.Atoi(meta.GetExternalName(cr))
+
+	if err != nil {
+		return managed.ExternalDelete{}, errors.New(errIDnotInt)
+	}
+
+	_, err = e.client.DeleteProjectApprovalRule(
+		*cr.Spec.ForProvider.ProjectID,
+		ruleID,
+		gitlab.WithContext(ctx),
+	)
+
+	return managed.ExternalDelete{}, errors.Wrap(err, errDeleteFailed)
+}
+
+func (e *external) Disconnect(ctx context.Context) error {
+	// Disconnect is not implemented as it is a new method required by the SDK
+	return nil
+}
+
+func (e *external) updateExternalName(ctx context.Context, cr *v1alpha1.ApprovalRule, approvalRule *gitlab.ProjectApprovalRule) error {
+	meta.SetExternalName(cr, strconv.Itoa(approvalRule.ID))
+	return e.kube.Update(ctx, cr)
+}
diff --git a/pkg/controller/projects/setup.go b/pkg/controller/projects/setup.go
index a1ce85e..32a9011 100644
--- a/pkg/controller/projects/setup.go
+++ b/pkg/controller/projects/setup.go
@@ -21,6 +21,7 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/accesstokens"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/approvalrules"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/deploykeys"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/deploytokens"
 	"github.com/crossplane-contrib/provider-gitlab/pkg/controller/projects/hooks"
@@ -41,6 +42,7 @@ func Setup(mgr ctrl.Manager, o controller.Options) error {
 		variables.SetupVariable,
 		deploykeys.SetupDeployKey,
 		pipelineschedules.SetupPipelineSchedule,
+		approvalrules.SetupRules,
 	} {
 		if err := setup(mgr, o); err != nil {
 			return err


Test patch:
diff --git a/pkg/clients/projects/approvalrules_test.go b/pkg/clients/projects/approvalrules_test.go
new file mode 100644
index 0000000..cd767c5
--- /dev/null
+++ b/pkg/clients/projects/approvalrules_test.go
@@ -0,0 +1,191 @@
+/*
+Copyright 2021 The Crossplane Authors.
+
+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.
+*/
+
+package projects
+
+import (
+	"testing"
+
+	"gitlab.com/gitlab-org/api/client-go"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/projects/v1alpha1"
+)
+
+func TestIsUsernamesUpToDate(t *testing.T) {
+	cases := map[string]struct {
+		cr   *v1alpha1.ApprovalRuleParameters
+		in   *gitlab.ProjectApprovalRule
+		want bool
+	}{
+		"NilUsernamesEmptyUsers": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				Usernames: nil,
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{},
+			},
+			want: true,
+		},
+		"EmptyUsernamesEmptyUsers": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				Usernames: &[]string{},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{},
+			},
+			want: true,
+		},
+		"MatchingUsernames": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				Usernames: &[]string{"user1", "user2"},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{Username: "user1"},
+					{Username: "user2"},
+				},
+			},
+			want: true,
+		},
+		"DifferentUsernames": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				Usernames: &[]string{"user1", "user2"},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{Username: "user1"},
+					{Username: "user3"},
+				},
+			},
+			want: false,
+		},
+		"DifferentLengths": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				Usernames: &[]string{"user1"},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{Username: "user1"},
+					{Username: "user2"},
+				},
+			},
+			want: false,
+		},
+		"NilUsernamesWithUsers": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				Usernames: nil,
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{Username: "user1"},
+				},
+			},
+			want: false,
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got := isUsernamesUpToDate(tc.cr, tc.in)
+			if got != tc.want {
+				t.Errorf("isUsernamesUpToDate() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestIsUserIDsUpToDate(t *testing.T) {
+	cases := map[string]struct {
+		cr   *v1alpha1.ApprovalRuleParameters
+		in   *gitlab.ProjectApprovalRule
+		want bool
+	}{
+		"NilUserIDsEmptyUsers": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				UserIDs: nil,
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{},
+			},
+			want: true,
+		},
+		"EmptyUserIDsEmptyUsers": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				UserIDs: &[]int{},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{},
+			},
+			want: true,
+		},
+		"MatchingUserIDs": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				UserIDs: &[]int{1, 2},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{ID: 1},
+					{ID: 2},
+				},
+			},
+			want: true,
+		},
+		"DifferentUserIDs": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				UserIDs: &[]int{1, 2},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{ID: 1},
+					{ID: 3},
+				},
+			},
+			want: false,
+		},
+		"DifferentLengths": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				UserIDs: &[]int{1},
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{ID: 1},
+					{ID: 2},
+				},
+			},
+			want: false,
+		},
+		"NilUserIDsWithUsers": {
+			cr: &v1alpha1.ApprovalRuleParameters{
+				UserIDs: nil,
+			},
+			in: &gitlab.ProjectApprovalRule{
+				Users: []*gitlab.BasicUser{
+					{ID: 1},
+				},
+			},
+			want: false,
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got := isUserIDsUpToDate(tc.cr, tc.in)
+			if got != tc.want {
+				t.Errorf("isUserIDsUpToDate() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
diff --git a/pkg/controller/projects/approvalrules/controller_test.go b/pkg/controller/projects/approvalrules/controller_test.go
new file mode 100644
index 0000000..443b232
--- /dev/null
+++ b/pkg/controller/projects/approvalrules/controller_test.go
@@ -0,0 +1,548 @@
+/*
+Copyright 2021 The Crossplane Authors.
+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.
+*/
+
+package approvalrules
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+	"github.com/google/go-cmp/cmp"
+	"github.com/pkg/errors"
+	gitlab "gitlab.com/gitlab-org/api/client-go"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/crossplane-contrib/provider-gitlab/apis/projects/v1alpha1"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/projects"
+	"github.com/crossplane-contrib/provider-gitlab/pkg/clients/projects/fake"
+)
+
+var (
+	unexpecedItem                 resource.Managed
+	errBoom                       = errors.New("boom")
+	projectID                     = 0
+	approvalsRequired             = 1
+	users                         = []*gitlab.BasicUser{{ID: 123, Username: "abc"}, {ID: 456, Username: "testUser"}}
+	groups                        = []*gitlab.Group{{ID: 99}}
+	protectedBranches             = []*gitlab.ProtectedBranch{{ID: 1}, {ID: 2}}
+	name                          = "name"
+	ruleType                      = "any_approver"
+	appliesToAllProtectedBranches = true
+)
+
+type args struct {
+	projectApprovalRule projects.ApprovalRulesClient
+	kube                client.Client
+	cr                  resource.Managed
+}
+
+type projectModifier func(*v1alpha1.ApprovalRule)
+
+func withConditions(c ...xpv1.Condition) projectModifier {
+	return func(cr *v1alpha1.ApprovalRule) { cr.Status.ConditionedStatus.Conditions = c }
+}
+
+func withProjectID() projectModifier {
+	return func(r *v1alpha1.ApprovalRule) { r.Spec.ForProvider.ProjectID = &projectID }
+}
+
+func withSpec(s v1alpha1.ApprovalRuleParameters) projectModifier {
+	return func(r *v1alpha1.ApprovalRule) { r.Spec.ForProvider = s }
+}
+
+func withExternalName(approvalRuleId string) projectModifier {
+	return func(r *v1alpha1.ApprovalRule) { meta.SetExternalName(r, approvalRuleId) }
+}
+
+func projectApprovalRule(m ...projectModifier) *v1alpha1.ApprovalRule {
+	cr := &v1alpha1.ApprovalRule{}
+	for _, f := range m {
+		f(cr)
+	}
+	return cr
+}
+
+func TestConnect(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalClient
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotApprovalRule),
+			},
+		},
+		"ProviderConfigRefNotGivenError": {
+			args: args{
+				cr: projectApprovalRule(),
+			},
+			want: want{
+				cr:  projectApprovalRule(),
+				err: errors.New("providerConfigRef is not given"),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			c := &connector{newGitlabClientFn: nil}
+			o, err := c.Connect(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestObserve(t *testing.T) {
+
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalObservation
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotApprovalRule),
+			},
+		},
+		"ExternalNameMissing": {
+			args: args{
+				cr: projectApprovalRule(),
+			},
+			want: want{
+				cr: projectApprovalRule(),
+				result: managed.ExternalObservation{
+					ResourceExists: false,
+				},
+			},
+		},
+		"ExternalNameNotInt": {
+			args: args{
+				cr: projectApprovalRule(
+					withExternalName("abc"),
+				),
+			},
+			want: want{
+				cr:     projectApprovalRule(withExternalName("abc")),
+				result: managed.ExternalObservation{},
+				err:    errors.New(errIDnotInt),
+			},
+		},
+		"ErrProjectIDMissing": {
+			args: args{
+				cr: projectApprovalRule(withExternalName("123")),
+			},
+			want: want{
+				cr:     projectApprovalRule(withExternalName("123")),
+				result: managed.ExternalObservation{},
+				err:    errors.New(errProjectIDMissing),
+			},
+		},
+		"ErrGet404": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockGetProjectApprovalRule: func(pid any, ruleID int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return nil, &gitlab.Response{Response: &http.Response{StatusCode: 404}}, errBoom
+					},
+				},
+				cr: projectApprovalRule(
+					withProjectID(),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						ProjectID:         &projectID,
+						Name:              &name,
+					})),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withProjectID(),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					})),
+				result: managed.ExternalObservation{ResourceExists: false},
+				err:    nil,
+			},
+		},
+		"ErrGet": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockGetProjectApprovalRule: func(pid any, ruleID int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return nil, nil, errBoom
+					},
+				},
+				cr: projectApprovalRule(withProjectID(), withExternalName("1")),
+			},
+			want: want{
+				cr:     projectApprovalRule(withProjectID(), withExternalName("1")),
+				result: managed.ExternalObservation{},
+				err:    errors.Wrap(errBoom, errors.New(errObserveFailed).Error()),
+			},
+		},
+		"SuccessfulAvailable": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockGetProjectApprovalRule: func(pid any, ruleID int, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return &gitlab.ProjectApprovalRule{
+							ApprovalsRequired: approvalsRequired,
+							Name:              name,
+						}, &gitlab.Response{}, nil
+					},
+				},
+				cr: projectApprovalRule(
+					withProjectID(),
+					withExternalName("123"),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					}),
+				),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withConditions(xpv1.Available()),
+					withProjectID(),
+					withExternalName("123"),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					}),
+				),
+				result: managed.ExternalObservation{
+					ResourceExists:          true,
+					ResourceUpToDate:        true,
+					ResourceLateInitialized: false,
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{client: tc.projectApprovalRule}
+			o, err := e.Observe(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestCreate(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalCreation
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotApprovalRule),
+			},
+		},
+		"SuccessfulCreation": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockCreateProjectApprovalRule: func(pid any, opt *gitlab.CreateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return &gitlab.ProjectApprovalRule{
+							ID:                            projectID,
+							Name:                          name,
+							RuleType:                      ruleType,
+							ApprovalsRequired:             approvalsRequired,
+							Users:                         users,
+							Groups:                        groups,
+							ProtectedBranches:             protectedBranches,
+							AppliesToAllProtectedBranches: appliesToAllProtectedBranches,
+						}, &gitlab.Response{}, nil
+					},
+				},
+				kube: &test.MockClient{MockUpdate: test.NewMockUpdateFn(nil)},
+
+				cr: projectApprovalRule(
+					withSpec(v1alpha1.ApprovalRuleParameters{ProjectID: &projectID}),
+				),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withProjectID(),
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{ProjectID: &projectID}),
+					withConditions(xpv1.Creating()),
+				),
+				result: managed.ExternalCreation{},
+			},
+		},
+		"FailedCreation": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockCreateProjectApprovalRule: func(pid any, opt *gitlab.CreateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return &gitlab.ProjectApprovalRule{}, &gitlab.Response{}, errBoom
+					},
+				},
+				cr: projectApprovalRule(
+					withSpec(v1alpha1.ApprovalRuleParameters{ProjectID: &projectID}),
+				),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withProjectID(),
+					withSpec(v1alpha1.ApprovalRuleParameters{ProjectID: &projectID}),
+					withConditions(xpv1.Creating()),
+				),
+				err: errors.Wrap(errBoom, errCreateFailed),
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{kube: tc.kube, client: tc.projectApprovalRule}
+			o, err := e.Create(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+
+}
+
+func TestUpdate(t *testing.T) {
+	type want struct {
+		cr     resource.Managed
+		result managed.ExternalUpdate
+		err    error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotApprovalRule),
+			},
+		},
+		"SuccessfulUpdate": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockUpdateProjectApprovalRule: func(pid any, approvalRule int, opt *gitlab.UpdateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return &gitlab.ProjectApprovalRule{
+							ID:                            projectID,
+							Name:                          name,
+							RuleType:                      ruleType,
+							ApprovalsRequired:             approvalsRequired,
+							Users:                         users,
+							Groups:                        groups,
+							ProtectedBranches:             protectedBranches,
+							AppliesToAllProtectedBranches: appliesToAllProtectedBranches,
+						}, &gitlab.Response{}, nil
+					},
+				},
+				cr: projectApprovalRule(
+					withProjectID(),
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					}),
+				),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withProjectID(),
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					}),
+				),
+			},
+		},
+		"FailedUpdate": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockUpdateProjectApprovalRule: func(pid any, approvalRule int, opt *gitlab.UpdateProjectLevelRuleOptions, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectApprovalRule, *gitlab.Response, error) {
+						return &gitlab.ProjectApprovalRule{}, &gitlab.Response{}, errBoom
+					},
+				},
+				cr: projectApprovalRule(withProjectID(),
+					withExternalName(fmt.Sprintf("%d", projectID)),
+				),
+			},
+			want: want{
+				cr: projectApprovalRule(withProjectID(),
+					withExternalName(fmt.Sprintf("%d", projectID)),
+				),
+				err: errors.Wrap(errBoom, errors.New(errUpdateFailed).Error()),
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{client: tc.projectApprovalRule}
+			o, err := e.Update(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.result, o); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestDelete(t *testing.T) {
+	type want struct {
+		cr  resource.Managed
+		err error
+	}
+
+	cases := map[string]struct {
+		args
+		want
+	}{
+		"InValidInput": {
+			args: args{
+				cr: unexpecedItem,
+			},
+			want: want{
+				cr:  unexpecedItem,
+				err: errors.New(errNotApprovalRule),
+			},
+		},
+		"SuccessfulDeletion": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockDeleteProjectApprovalRule: func(pid any, approvalRule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, nil
+					},
+				},
+				cr: projectApprovalRule(
+					withProjectID(),
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					})),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{
+						ApprovalsRequired: &approvalsRequired,
+						Name:              &name,
+						ProjectID:         &projectID,
+					})),
+				err: nil,
+			},
+		},
+		"FailedDeletion": {
+			args: args{
+				projectApprovalRule: &fake.MockClient{
+					MockDeleteProjectApprovalRule: func(pid any, approvalRule int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
+						return &gitlab.Response{}, errBoom
+					},
+				},
+				cr: projectApprovalRule(
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{ProjectID: &projectID})),
+			},
+			want: want{
+				cr: projectApprovalRule(
+					withExternalName(fmt.Sprintf("%d", projectID)),
+					withSpec(v1alpha1.ApprovalRuleParameters{ProjectID: &projectID})),
+				err: errors.Wrap(errBoom, errors.New(errDeleteFailed).Error()),
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			e := &external{client: tc.projectApprovalRule}
+			_, err := e.Delete(context.Background(), tc.args.cr)
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+			if diff := cmp.Diff(tc.want.cr, tc.args.cr, test.EquateConditions()); diff != "" {
+				t.Errorf("r: -want, +got:\n%s", diff)
+			}
+		})
+	}
+
+}