Repository: kubesphere/ks-devops
Pull request #750 (2022-07-25 06:39:29)

Add a document about the API permission
<!-- Thanks for sending a pull request! Here are some tips for you:

1. If you want **faster** PR reviews, read how: https://github.com/kubesphere/community/blob/master/developer-guide/development/the-pr-author-guide-to-getting-through-code-review.md
2. In case you want to know how your PR got reviewed, read: https://github.com/kubesphere/community/blob/master/developer-guide/development/code-review-guide.md
3. Here are some coding conventions followed by the KubeSphere community: https://github.com/kubesphere/community/blob/master/developer-guide/development/coding-conventions.md
4. Additional open-source best practice: https://github.com/LinuxSuRen/open-source-best-practice
-->

### What type of PR is this?
<!-- 
Add one of the following kinds:
/kind bug
/kind cleanup
/kind feature
/kind design
/kind chore

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->
/kind documentation

### What this PR does / why we need it:

### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
Please leave it or change # to be None if there is no corresponding issue that exists
-->
Fixes #

### Special notes for reviewers:
<!--
You can use the following command to let the DevOps SIG members help you to review your PR.
/cc @kubesphere/sig-devops 
And please avoid cc any individual.
-->
Please check the following list before waiting reviewers:

- [ ] Already committed the CRD files to [the Helm Chart](https://github.com/kubesphere-sigs/ks-devops-helm-chart/) if you created some new CRDs
- [ ] Already [added the permission](https://github.com/kubesphere/ks-installer/blob/9e063b085a0e43fdb3d0d9e3e7f4149146f14b9c/roles/ks-core/prepare/files/ks-init/role-templates.yaml) for the new API
- [ ] Already added the RBAC markers for the new controllers

### Does this PR introduce a user-facing change??
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended-release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

Please keep the note be same as your PR title if you believe it should be in the release notes.
-->
```release-note
None
```


ks-ci-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please ask for approval from **linuxsuren** after the PR has been reviewed.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubesphere/ks-devops/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["linuxsuren"]} -->

Patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
deleted file mode 100644
index 6e3fdaf..0000000
--- a/.devcontainer/Dockerfile
+++ /dev/null
@@ -1,31 +0,0 @@
-# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ubuntu/.devcontainer/base.Dockerfile
-
-# [Choice] Ubuntu version: bionic, focal
-ARG VARIANT="focal"
-FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
-
-RUN apt update -y && \
-    apt install docker.io -y && \
-    sudo apt install bash-completion -y
-
-RUN sudo apt install golang-1.16 -y
-
-RUN sudo apt install make -y
-
-RUN sudo rm -rf /usr/bin/hd && \
-    curl -L https://github.com/linuxsuren/http-downloader/releases/latest/download/hd-linux-amd64.tar.gz | tar xzv && \
-    mv hd /usr/local/bin && \
-    hd fetch && \
-    hd install cli/cli && \
-    hd install ks && \
-    hd install minikube && \
-    hd install helm
-
-RUN ks alias init && \
-    ks completion bash > /etc/bash_completion.d/ks
-
-RUN echo "export PATH=/usr/lib/go-1.16/bin:$PATH" >> /etc/bash.bashrc && \
-    /usr/lib/go-1.16/bin/go get -v golang.org/x/tools/gopls
-
-RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
-    sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
deleted file mode 100644
index 00b9671..0000000
--- a/.devcontainer/devcontainer.json
+++ /dev/null
@@ -1,35 +0,0 @@
-// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
-// https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/go
-{
-	"name": "Go",
-	"build": {
-		"dockerfile": "Dockerfile",
-		"args": {
-			"VARIANT": "focal",
-		}
-	},
-	"runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
-
-	// Set *default* container specific settings.json values on container create.
-	"settings": {
-		"go.toolsManagement.checkForUpdates": "local",
-		"go.useLanguageServer": true,
-		"go.gopath": "/home/vscode/go",
-		"go.goroot": "/usr/lib/go-1.16"
-	},
-
-	// Add the IDs of extensions you want installed when the container is created.
-	"extensions": [
-		"golang.Go",
-		"ms-azuretools.vscode-docker"
-	],
-
-	// Use 'forwardPorts' to make a list of ports inside the container available locally.
-	"forwardPorts": [30880, 30180],
-
-	// Use 'postCreateCommand' to run commands after the container is created.
-	"postCreateCommand": "bash .devcontainer/init.sh",
-
-	"mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
-	"remoteUser": "vscode"
-}
diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh
deleted file mode 100644
index 1147001..0000000
--- a/.devcontainer/init.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-# start minikube
-sudo chown vscode:vscode /var/run/docker.sock
-minikube-linux-amd64 start --ports=30880
-
-# ks init
-ks alias init
diff --git a/Makefile b/Makefile
index 4b3fd99..dd76cf6 100644
--- a/Makefile
+++ b/Makefile
@@ -144,7 +144,7 @@ ifeq (, $(shell which controller-gen))
 	CONTROLLER_GEN_TMP_DIR=$$(mktemp -d) ;\
 	cd $$CONTROLLER_GEN_TMP_DIR ;\
 	go mod init tmp ;\
-	go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.6.2 ;\
+	go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.6.2 ;\
 	rm -rf $$CONTROLLER_GEN_TMP_DIR ;\
 	}
 CONTROLLER_GEN=$(GOBIN)/controller-gen
diff --git a/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml b/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml
index f3be2d6..c2f9e1a 100644
--- a/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml
+++ b/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml
@@ -17,6 +17,9 @@ spec:
   scope: Namespaced
   versions:
   - additionalPrinterColumns:
+    - jsonPath: .spec.provider
+      name: Provider
+      type: string
     - jsonPath: .spec.server
       name: Server
       type: string
@@ -40,8 +43,12 @@ spec:
           spec:
             description: GitRepositorySpec represents the desired state of a GitRepository
             properties:
+              owner:
+                type: string
               provider:
                 type: string
+              repo:
+                type: string
               secret:
                 description: SecretReference represents a Secret Reference. It has
                   enough information to retrieve secret in any namespace
@@ -55,6 +62,8 @@ spec:
                       name must be unique.
                     type: string
                 type: object
+              server:
+                type: string
               url:
                 type: string
               webhooks:
@@ -69,6 +78,17 @@ spec:
                   type: object
                 type: array
             type: object
+          status:
+            description: GitRepositoryStatus represents the status of a git repository
+            properties:
+              connection:
+                description: Connection indicates if the connection is ok
+                type: string
+              message:
+                description: Message describes the message when trying to connect
+                  it
+                type: string
+            type: object
         type: object
     served: true
     storage: true
diff --git a/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml b/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml
index f0603df..9f4444e 100644
--- a/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml
+++ b/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml
@@ -395,9 +395,8 @@ spec:
                     - name
                     type: object
                   type:
-                    description: 'INSERT ADDITIONAL SPEC FIELDS - desired state of
-                      cluster Important: Run "make" to regenerate code after modifying
-                      this file'
+                    description: PipelineType is an alias of string that represents
+                      the type of Pipelines
                     type: string
                 required:
                 - type
diff --git a/config/crd/bases/devops.kubesphere.io_pipelines.yaml b/config/crd/bases/devops.kubesphere.io_pipelines.yaml
index 62243d3..cdb27ee 100644
--- a/config/crd/bases/devops.kubesphere.io_pipelines.yaml
+++ b/config/crd/bases/devops.kubesphere.io_pipelines.yaml
@@ -334,8 +334,8 @@ spec:
                 - name
                 type: object
               type:
-                description: 'INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
-                  Important: Run "make" to regenerate code after modifying this file'
+                description: PipelineType is an alias of string that represents the
+                  type of Pipelines
                 type: string
             required:
             - type
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index cebf48e..c4f06c9 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -121,26 +121,6 @@ rules:
   - list
   - update
   - watch
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - clustertemplates
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - clustertemplates/status
-  verbs:
-  - get
-  - patch
-  - update
 - apiGroups:
   - devops.kubesphere.io
   resources:
@@ -260,26 +240,6 @@ rules:
   - secrets
   verbs:
   - get
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - templates
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - templates/status
-  verbs:
-  - get
-  - patch
-  - update
 - apiGroups:
   - devops.kubesphere.io
   resources:
diff --git a/config/samples/gitrepository.yaml b/config/samples/gitrepository.yaml
index 07bd1e9..5e22782 100644
--- a/config/samples/gitrepository.yaml
+++ b/config/samples/gitrepository.yaml
@@ -2,11 +2,8 @@ apiVersion: devops.kubesphere.io/v1alpha3
 kind: GitRepository
 metadata:
   name: gitrepository
+  namespace: testxb4m8
 spec:
   provider: github
-  url: https://github.com/linuxsuren/test
-  secret:
-    name: github
-    namespace: default
-  webhooks:
-    - name: webhook
+  owner: linuxsuren
+  repo: test
diff --git a/controllers/gitrepository/gitrepository_amend_controller.go b/controllers/gitrepository/gitrepository_amend_controller.go
index 31a0969..a42f0b6 100644
--- a/controllers/gitrepository/gitrepository_amend_controller.go
+++ b/controllers/gitrepository/gitrepository_amend_controller.go
@@ -45,15 +45,46 @@ func (r *AmendReconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err e
 		return
 	}
 
-	if amendGitlabURL(repo) {
-		err = r.Update(ctx, repo)
+	for _, amend := range gitProviderAmends {
+		if amend.Match(repo) {
+			if amend.Amend(repo) {
+				err = r.Update(ctx, repo)
+			}
+			break
+		}
 	}
 	return
 }
 
-func amendGitlabURL(repo *v1alpha3.GitRepository) (changed bool) {
-	if strings.ToLower(repo.Spec.Provider) != "gitlab" {
-		return
+var gitProviderAmends []GitProviderAmend
+
+func init() {
+	gitProviderAmends = []GitProviderAmend{
+		&gitlabPublicAmend{},
+		&githubPublicAmend{},
+		&bitbucketPublicAmend{},
+	}
+}
+
+// GitProviderAmend is the interface of all the potential git providers
+type GitProviderAmend interface {
+	// Match determine if the implement wants to amend it
+	Match(*v1alpha3.GitRepository) bool
+	// Amend tries to amend it, returns true if any changes happened
+	Amend(*v1alpha3.GitRepository) bool
+}
+
+type gitlabPublicAmend struct {
+}
+
+func (a *gitlabPublicAmend) Match(repo *v1alpha3.GitRepository) bool {
+	return strings.ToLower(repo.Spec.Provider) == "gitlab"
+}
+
+func (a *gitlabPublicAmend) Amend(repo *v1alpha3.GitRepository) (changed bool) {
+	if repo.Spec.URL == "" {
+		repo.Spec.URL = fmt.Sprintf("https://gitlab.com/%s/%s",
+			repo.Spec.Owner, repo.Spec.Repo)
 	}
 
 	if !strings.HasSuffix(repo.Spec.URL, ".git") {
@@ -63,6 +94,38 @@ func amendGitlabURL(repo *v1alpha3.GitRepository) (changed bool) {
 	return
 }
 
+type githubPublicAmend struct {
+}
+
+func (a *githubPublicAmend) Match(repo *v1alpha3.GitRepository) bool {
+	return strings.ToLower(repo.Spec.Provider) == "github"
+}
+
+func (a *githubPublicAmend) Amend(repo *v1alpha3.GitRepository) (changed bool) {
+	if repo.Spec.URL == "" {
+		repo.Spec.URL = fmt.Sprintf("https://github.com/%s/%s",
+			repo.Spec.Owner, repo.Spec.Repo)
+		changed = true
+	}
+	return
+}
+
+type bitbucketPublicAmend struct {
+}
+
+func (a *bitbucketPublicAmend) Match(repo *v1alpha3.GitRepository) bool {
+	return strings.ToLower(repo.Spec.Provider) == "bitbucket"
+}
+
+func (a *bitbucketPublicAmend) Amend(repo *v1alpha3.GitRepository) (changed bool) {
+	if repo.Spec.URL == "" {
+		repo.Spec.URL = fmt.Sprintf("https://bitbucket.org/%s/%s",
+			repo.Spec.Owner, repo.Spec.Repo)
+		changed = true
+	}
+	return
+}
+
 func (r *AmendReconciler) GetName() string {
 	return "git-repository-amend"
 }
diff --git a/controllers/gitrepository/gitrepository_webhook_controller.go b/controllers/gitrepository/gitrepository_webhook_controller.go
index b6e941c..eafa898 100644
--- a/controllers/gitrepository/gitrepository_webhook_controller.go
+++ b/controllers/gitrepository/gitrepository_webhook_controller.go
@@ -87,7 +87,7 @@ func (r *Reconciler) createOrUpdateWebhook(repo *v1alpha3.GitRepository) (err er
 
 	repoAddress := getRepo(repo)
 	if repoAddress == "" {
-		err = fmt.Errorf("not support: %v", repo.Spec.URL)
+		err = fmt.Errorf("failed to createOrUpdate webhook due to repo address is empty")
 		return
 	}
 
diff --git a/controllers/template/clustertemplate_controller.go b/controllers/template/clustertemplate_controller.go
deleted file mode 100644
index f39bb96..0000000
--- a/controllers/template/clustertemplate_controller.go
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
-Copyright 2022 The KubeSphere 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 template
-
-import (
-	"context"
-	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
-
-	"github.com/go-logr/logr"
-	"k8s.io/apimachinery/pkg/runtime"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-)
-
-// ClusterTemplateReconciler reconciles a ClusterTemplate object
-type ClusterTemplateReconciler struct {
-	client.Client
-	Log    logr.Logger
-	Scheme *runtime.Scheme
-}
-
-//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=clustertemplates,verbs=get;list;watch;create;update;patch;delete
-//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=clustertemplates/status,verbs=get;update;patch
-
-// Reconcile is part of the main kubernetes reconciliation loop which aims to
-// move the current state of the cluster closer to the desired state.
-// TODO(user): Modify the Reconcile function to compare the state specified by
-// the ClusterTemplate object against the actual cluster state, and then
-// perform operations to make the cluster state reflect the state specified by
-// the user.
-//
-// For more details, check Reconcile and its Result here:
-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.6.4/pkg/reconcile
-func (r *ClusterTemplateReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
-	_ = context.Background()
-	_ = r.Log.WithValues("clustertemplate", req.NamespacedName)
-
-	// TODO(user): your logic here
-
-	return ctrl.Result{}, nil
-}
-
-// SetupWithManager sets up the controller with the Manager.
-func (r *ClusterTemplateReconciler) SetupWithManager(mgr ctrl.Manager) error {
-	return ctrl.NewControllerManagedBy(mgr).
-		For(&v1alpha3.ClusterTemplate{}).
-		Complete(r)
-}
diff --git a/controllers/template/template_controller.go b/controllers/template/template_controller.go
deleted file mode 100644
index ca7bb48..0000000
--- a/controllers/template/template_controller.go
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
-Copyright 2022 The KubeSphere 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 template
-
-import (
-	"context"
-	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
-
-	"github.com/go-logr/logr"
-	"k8s.io/apimachinery/pkg/runtime"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-)
-
-// Reconciler reconciles a Template object
-type Reconciler struct {
-	client.Client
-	Log    logr.Logger
-	Scheme *runtime.Scheme
-}
-
-//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=templates,verbs=get;list;watch;create;update;patch;delete
-//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=templates/status,verbs=get;update;patch
-
-// Reconcile is part of the main kubernetes reconciliation loop which aims to
-// move the current state of the cluster closer to the desired state.
-// TODO(user): Modify the Reconcile function to compare the state specified by
-// the Template object against the actual cluster state, and then
-// perform operations to make the cluster state reflect the state specified by
-// the user.
-//
-// For more details, check Reconcile and its Result here:
-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.6.4/pkg/reconcile
-func (r *Reconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
-	_ = context.Background()
-	_ = r.Log.WithValues("template", req.NamespacedName)
-
-	// TODO(user): your logic here
-
-	return ctrl.Result{}, nil
-}
-
-// SetupWithManager sets up the controller with the Manager.
-func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
-	return ctrl.NewControllerManagedBy(mgr).
-		For(&v1alpha3.Template{}).
-		Complete(r)
-}
diff --git a/docs/README.md b/docs/README.md
index ed3d00e..f1fea81 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,6 +8,7 @@ This is the right place if you want to know more details about `ks-devops`.
 * [Swagger Support](swagger.md)
 * [Addon management](addon.md)
 * [Pipeline Template Design](pipeline-template.md)
+* [API Permission](permission.md)
 
 ## Create a new CRD
 
diff --git a/docs/permission.md b/docs/permission.md
new file mode 100644
index 0000000..fcc72d1
--- /dev/null
+++ b/docs/permission.md
@@ -0,0 +1,20 @@
+We need to pay attention on the permission when there are APIs changed.
+There are three types of permissions:
+
+* Anonymous
+* Global or cluster level
+* Resource specific
+
+Please don't forget to add the corresponding permission setting to [role-templates.yaml](https://github.com/kubesphere/ks-installer/blob/master/roles/ks-core/prepare/files/ks-init/role-templates.yaml) 
+when you are trying to change (add, remove) any APIs.
+
+## Anonymous
+You could update the `GlobalRole` which is [anonymous](https://github.com/kubesphere/ks-installer/blob/e9e399d74a2fd8dbbb6477a95afb91c40f423b72/roles/ks-core/prepare/files/ks-init/role-templates.yaml#L91) when you are trying to create a new anonymous API.
+
+## Global
+As we know, some APIs does not belong to any CR (custom resource). For example: `ci/nodelabels`.
+Update [here](https://github.com/kubesphere/ks-installer/blob/e9e399d74a2fd8dbbb6477a95afb91c40f423b72/roles/ks-core/prepare/files/ks-init/role-templates.yaml#L175) when you update a global API.
+
+## Resource specific
+You could create (or update) an CR (custom resource) that is `RoleBase` when you are trying to 
+create a new resource specific API. Such as, [role-template-manage-pipelines](https://github.com/kubesphere/ks-installer/blob/e9e399d74a2fd8dbbb6477a95afb91c40f423b72/roles/ks-core/prepare/files/ks-init/role-templates.yaml#L3323).
diff --git a/pkg/api/devops/v1alpha3/gitrepository.go b/pkg/api/devops/v1alpha3/gitrepository.go
index 9062f7f..5665914 100644
--- a/pkg/api/devops/v1alpha3/gitrepository.go
+++ b/pkg/api/devops/v1alpha3/gitrepository.go
@@ -32,12 +32,22 @@ const GitRepoFinalizerName = "finalizer.gitrepository.devops.kubesphere.io"
 
 // GitRepository is the Schema for the webhook API
 // +k8s:openapi-gen=true
+// +kubebuilder:printcolumn:name="Provider",type="string",JSONPath=".spec.provider"
 // +kubebuilder:printcolumn:name="Server",type="string",JSONPath=".spec.server"
 type GitRepository struct {
 	metav1.TypeMeta   `json:",inline"`
 	metav1.ObjectMeta `json:"metadata,omitempty"`
 
-	Spec GitRepositorySpec `json:"spec,omitempty"`
+	Spec   GitRepositorySpec   `json:"spec,omitempty"`
+	Status GitRepositoryStatus `json:"status,omitempty"`
+}
+
+// GitRepositoryStatus represents the status of a git repository
+type GitRepositoryStatus struct {
+	// Connection indicates if the connection is ok
+	Connection string `json:"connection,omitempty"`
+	// Message describes the message when trying to connect it
+	Message string `json:"message,omitempty"`
 }
 
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -53,6 +63,9 @@ type GitRepositoryList struct {
 type GitRepositorySpec struct {
 	Provider string                    `json:"provider,omitempty"`
 	URL      string                    `json:"url,omitempty"`
+	Server   string                    `json:"server,omitempty"`
+	Owner    string                    `json:"owner,omitempty"`
+	Repo     string                    `json:"repo,omitempty"`
 	Secret   *v1.SecretReference       `json:"secret,omitempty"`
 	Webhooks []v1.LocalObjectReference `json:"webhooks,omitempty"`
 }
diff --git a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
index de24385..b431e61 100644
--- a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
+++ b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
@@ -639,6 +639,7 @@ func (in *GitRepository) DeepCopyInto(out *GitRepository) {
 	out.TypeMeta = in.TypeMeta
 	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
 	in.Spec.DeepCopyInto(&out.Spec)
+	out.Status = in.Status
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepository.
@@ -716,6 +717,21 @@ func (in *GitRepositorySpec) DeepCopy() *GitRepositorySpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GitRepositoryStatus) DeepCopyInto(out *GitRepositoryStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepositoryStatus.
+func (in *GitRepositoryStatus) DeepCopy() *GitRepositoryStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(GitRepositoryStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GitSource) DeepCopyInto(out *GitSource) {
 	*out = *in


Test patch:
diff --git a/controllers/gitrepository/gitrepository_amend_controller_test.go b/controllers/gitrepository/gitrepository_amend_controller_test.go
index bfd9a99..5d800e7 100644
--- a/controllers/gitrepository/gitrepository_amend_controller_test.go
+++ b/controllers/gitrepository/gitrepository_amend_controller_test.go
@@ -17,8 +17,19 @@ limitations under the License.
 package gitrepository
 
 import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
 	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	mgrcore "kubesphere.io/devops/controllers/core"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	controllerruntime "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/log"
 	"testing"
 )
 
@@ -51,6 +62,21 @@ func Test_amendGitlabURL(t *testing.T) {
 		verify: func(t *testing.T, repo *v1alpha3.GitRepository) {
 			assert.Equal(t, "https://gitlab.com/linuxsuren/test.git", repo.Spec.URL)
 		},
+	}, {
+		name: "gitlab, have owner and repo, but without URL",
+		args: args{
+			repo: &v1alpha3.GitRepository{
+				Spec: v1alpha3.GitRepositorySpec{
+					Provider: "gitlab",
+					Owner:    "linuxsuren",
+					Repo:     "test",
+				},
+			},
+		},
+		wantChanged: true,
+		verify: func(t *testing.T, repo *v1alpha3.GitRepository) {
+			assert.Equal(t, "https://gitlab.com/linuxsuren/test.git", repo.Spec.URL)
+		},
 	}}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -58,8 +84,181 @@ func Test_amendGitlabURL(t *testing.T) {
 				tt.verify = func(t *testing.T, repo *v1alpha3.GitRepository) {
 				}
 			}
-			assert.Equalf(t, tt.wantChanged, amendGitlabURL(tt.args.repo), "amendGitlabURL(%v)", tt.args.repo)
+			amend := gitlabPublicAmend{}
+			if !amend.Match(tt.args.repo) {
+				return
+			}
+			assert.Equalf(t, tt.wantChanged, amend.Amend(tt.args.repo), "amendGitlabURL(%v)", tt.args.repo)
 			tt.verify(t, tt.args.repo)
 		})
 	}
 }
+
+func TestAmendReconciler_SetupWithManager(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &AmendReconciler{
+				Client:   tt.fields.Client,
+				log:      tt.fields.log,
+				recorder: tt.fields.recorder,
+			}
+			mgr := &mgrcore.FakeManager{
+				Client: tt.fields.Client,
+				Scheme: schema,
+			}
+			tt.wantErr(t, r.SetupWithManager(mgr), fmt.Sprintf("SetupWithManager(%v)", mgr))
+		})
+	}
+}
+
+func TestAmendReconciler_Reconcile(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	repo := &v1alpha3.GitRepository{}
+	repo.SetName("fake")
+	repo.SetNamespace("ns")
+	repo.Spec.Provider = "gitlab"
+	repo.Spec.Owner = "linuxsuren"
+	repo.Spec.Repo = "test"
+	repo.Spec.URL = "https://gitlab.com/linuxsuren/test"
+
+	ghRepo := repo.DeepCopy()
+	ghRepo.Spec.Provider = "github"
+	ghRepo.Spec.URL = ""
+
+	bitbucket := ghRepo.DeepCopy()
+	bitbucket.Spec.Provider = "bitbucket"
+
+	type fields struct {
+		Client client.Client
+	}
+	type args struct {
+		req controllerruntime.Request
+	}
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		wantResult controllerruntime.Result
+		wantErr    assert.ErrorAssertionFunc
+		verify     func(tt *testing.T, c client.Client)
+	}{{
+		name: "not found",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema),
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "gitlab case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy()),
+		},
+		args: args{
+			req: controllerruntime.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: "ns",
+					Name:      "fake",
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "github case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, ghRepo.DeepCopy()),
+		},
+		args: args{
+			req: controllerruntime.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: "ns",
+					Name:      "fake",
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+		verify: func(tt *testing.T, c client.Client) {
+			repo := &v1alpha3.GitRepository{}
+			err := c.Get(context.Background(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "fake",
+			}, repo)
+			assert.Nil(tt, err)
+			assert.Equal(tt, "https://github.com/linuxsuren/test", repo.Spec.URL)
+		},
+	}, {
+		name: "bitbucket case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, bitbucket.DeepCopy()),
+		},
+		args: args{
+			req: controllerruntime.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: "ns",
+					Name:      "fake",
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+		verify: func(tt *testing.T, c client.Client) {
+			repo := &v1alpha3.GitRepository{}
+			err := c.Get(context.Background(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "fake",
+			}, repo)
+			assert.Nil(tt, err)
+			assert.Equal(tt, "https://bitbucket.org/linuxsuren/test", repo.Spec.URL)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &AmendReconciler{
+				Client: tt.fields.Client,
+				log:    log.NullLogger{},
+			}
+			gotResult, err := r.Reconcile(tt.args.req)
+			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req)) {
+				return
+			}
+			assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req)
+			if tt.verify != nil {
+				tt.verify(t, tt.fields.Client)
+			}
+		})
+	}
+}
diff --git a/controllers/gitrepository/gitrepository_webhook_controller_test.go b/controllers/gitrepository/gitrepository_webhook_controller_test.go
index 35d8506..a758d08 100644
--- a/controllers/gitrepository/gitrepository_webhook_controller_test.go
+++ b/controllers/gitrepository/gitrepository_webhook_controller_test.go
@@ -20,19 +20,23 @@ import (
 	"context"
 	"fmt"
 	"github.com/go-logr/logr"
-	testing2 "github.com/go-logr/logr/testing"
+	"github.com/h2non/gock"
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/stretchr/testify/assert"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/tools/record"
+	mgrcore "kubesphere.io/devops/controllers/core"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"reflect"
+	controllerruntime "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/log"
 	"strings"
 	"testing"
+	"time"
 )
 
 func Test_getRepo(t *testing.T) {
@@ -342,7 +346,6 @@ func TestReconciler_linkToWebhooks(t *testing.T) {
 
 	type fields struct {
 		Client client.Client
-		log    logr.Logger
 	}
 	type args struct {
 		repo *v1alpha3.GitRepository
@@ -357,7 +360,6 @@ func TestReconciler_linkToWebhooks(t *testing.T) {
 		name: "normal case",
 		fields: fields{
 			Client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy(), webhook.DeepCopy()),
-			log:    testing2.NullLogger{},
 		},
 		args: args{
 			repo: &repo,
@@ -378,7 +380,6 @@ func TestReconciler_linkToWebhooks(t *testing.T) {
 		name: "has errors",
 		fields: fields{
 			Client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy()),
-			log:    testing2.NullLogger{},
 		},
 		args: args{
 			repo: &repo,
@@ -389,7 +390,7 @@ func TestReconciler_linkToWebhooks(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			r := &Reconciler{
 				Client: tt.fields.Client,
-				log:    tt.fields.log,
+				log:    log.NullLogger{},
 			}
 			if err := r.linkToWebhooks(tt.args.repo); (err != nil) != tt.wantErr {
 				t.Errorf("linkToWebhooks() error = %v, wantErr %v", err, tt.wantErr)
@@ -647,3 +648,245 @@ func TestReconciler_getGitClient(t *testing.T) {
 		})
 	}
 }
+
+func TestReconciler_SetupWithManager(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				Client:   tt.fields.Client,
+				log:      tt.fields.log,
+				recorder: tt.fields.recorder,
+			}
+			mgr := &mgrcore.FakeManager{
+				Client: tt.fields.Client,
+				Scheme: schema,
+			}
+			tt.wantErr(t, r.SetupWithManager(mgr), fmt.Sprintf("SetupWithManager(%v)", mgr))
+		})
+	}
+}
+
+func TestReconciler_Reconcile(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	req := controllerruntime.Request{
+		NamespacedName: types.NamespacedName{
+			Namespace: "ns",
+			Name:      "fake",
+		},
+	}
+
+	repo := &v1alpha3.GitRepository{}
+	repo.Namespace = "ns"
+	repo.Name = "fake"
+	repo.Spec.URL = "https://github.com/linuxsuren/test"
+	repo.Spec.Provider = "github"
+
+	repoWithHook := repo.DeepCopy()
+	repoWithHook.Spec.Webhooks = []v1.LocalObjectReference{{
+		Name: "fake",
+	}}
+
+	repoWithSecret := repoWithHook.DeepCopy()
+	repoWithSecret.Spec.Secret = &v1.SecretReference{Name: "fake"}
+
+	invalidGitProvider := repoWithSecret.DeepCopy()
+	invalidGitProvider.Spec.Provider = "invalid"
+
+	repoWithSecretEmptyAddress := repoWithSecret.DeepCopy()
+	repoWithSecretEmptyAddress.Spec.URL = ""
+
+	webhook := &v1alpha3.Webhook{}
+	webhook.Namespace = "ns"
+	webhook.Name = "fake"
+	webhook.Spec.Server = "http://example.com"
+	webhook.Spec.SkipVerify = true
+	webhook.Spec.Events = []string{"push", "pull_request"}
+
+	secret := &v1.Secret{}
+	secret.Namespace = "ns"
+	secret.Name = "fake"
+
+	type fields struct {
+		Client client.Client
+	}
+	type args struct {
+		req controllerruntime.Request
+	}
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		wantResult controllerruntime.Result
+		wantErr    assert.ErrorAssertionFunc
+		prepare    func()
+	}{{
+		name:   "not found git repository",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema)},
+		args: args{
+			req: req,
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name:   "no webhooks in git repository",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy())},
+		args: args{
+			req: req,
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name:   "has one correct webhook in git repository, but no secret",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema, repoWithHook.DeepCopy(), webhook.DeepCopy())},
+		args:   args{req: req},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+		wantResult: controllerruntime.Result{Requeue: true, RequeueAfter: time.Minute},
+	}, {
+		name: "repo address is empty",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, repoWithSecretEmptyAddress.DeepCopy(), secret.DeepCopy(), webhook.DeepCopy()),
+		},
+		args: args{req: req},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+	}, {
+		name: "invliad git provider",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, invalidGitProvider.DeepCopy(), secret.DeepCopy(), webhook.DeepCopy()),
+		},
+		args: args{req: req},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+	}, {
+		name: "failed to list hooks",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, repoWithSecret.DeepCopy(), secret.DeepCopy(), webhook.DeepCopy()),
+		},
+		args: args{req: req},
+		prepare: func() {
+			var mockHeaders = map[string]string{
+				"X-GitHub-Request-Id":   "DD0E:6011:12F21A8:1926790:5A2064E2",
+				"X-RateLimit-Limit":     "60",
+				"X-RateLimit-Remaining": "59",
+				"X-RateLimit-Reset":     "1512076018",
+			}
+
+			var mockPageHeaders = map[string]string{
+				"Link": `<https://api.github.com/resource?page=2>; rel="next",` +
+					`<https://api.github.com/resource?page=1>; rel="prev",` +
+					`<https://api.github.com/resource?page=1>; rel="first",` +
+					`<https://api.github.com/resource?page=5>; rel="last"`,
+			}
+
+			gock.New("https://api.github.com").
+				Get("/repos/linuxsuren/test/hooks").
+				MatchParam("page", "1").
+				MatchParam("per_page", "30").
+				Reply(400).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				SetHeaders(mockPageHeaders).
+				File("testdata/hooks.json")
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+	}, {
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, repoWithSecret.DeepCopy(), secret.DeepCopy(), webhook.DeepCopy()),
+		},
+		args: args{req: req},
+		prepare: func() {
+			var mockHeaders = map[string]string{
+				"X-GitHub-Request-Id":   "DD0E:6011:12F21A8:1926790:5A2064E2",
+				"X-RateLimit-Limit":     "60",
+				"X-RateLimit-Remaining": "59",
+				"X-RateLimit-Reset":     "1512076018",
+			}
+
+			var mockPageHeaders = map[string]string{
+				"Link": `<https://api.github.com/resource?page=2>; rel="next",` +
+					`<https://api.github.com/resource?page=1>; rel="prev",` +
+					`<https://api.github.com/resource?page=1>; rel="first",` +
+					`<https://api.github.com/resource?page=5>; rel="last"`,
+			}
+
+			gock.New("https://api.github.com").
+				Get("/repos/linuxsuren/test/hooks").
+				MatchParam("page", "1").
+				MatchParam("per_page", "30").
+				Reply(200).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				SetHeaders(mockPageHeaders).
+				File("testdata/hooks.json")
+
+			gock.New("https://api.github.com").
+				Post("/repos/linuxsuren/test/hooks").
+				Reply(201).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				File("testdata/hook.json")
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			defer gock.Off()
+
+			r := &Reconciler{
+				Client: tt.fields.Client,
+				log:    log.NullLogger{},
+			}
+			if tt.prepare != nil {
+				tt.prepare()
+			}
+			gotResult, err := r.Reconcile(tt.args.req)
+			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req)) {
+				return
+			}
+			assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req)
+		})
+	}
+}
diff --git a/controllers/gitrepository/interface_test.go b/controllers/gitrepository/interface_test.go
new file mode 100644
index 0000000..4ac11b2
--- /dev/null
+++ b/controllers/gitrepository/interface_test.go
@@ -0,0 +1,62 @@
+/*
+Copyright 2022 The KubeSphere 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 gitrepository
+
+import (
+	"github.com/stretchr/testify/assert"
+	"kubesphere.io/devops/controllers/core"
+	"testing"
+)
+
+func TestInterfaceImplement(t *testing.T) {
+	type interInstance struct {
+		NamedReconciler core.NamedReconciler
+		GroupReconciler core.GroupReconciler
+	}
+
+	tests := []struct {
+		name     string
+		instance interInstance
+	}{{
+		name: "AmendReconciler",
+		instance: interInstance{
+			NamedReconciler: &AmendReconciler{},
+			GroupReconciler: &AmendReconciler{},
+		},
+	}, {
+		name: "Reconciler",
+		instance: interInstance{
+			NamedReconciler: &Reconciler{},
+			GroupReconciler: &Reconciler{},
+		},
+	}, {
+		name: "WebhookReconciler",
+		instance: interInstance{
+			NamedReconciler: &WebhookReconciler{},
+			GroupReconciler: &WebhookReconciler{},
+		},
+	}}
+	for i := range tests {
+		tt := tests[i]
+		t.Run(tt.name, func(t *testing.T) {
+			assert.NotNil(t, tt.instance.NamedReconciler)
+			assert.NotEmpty(t, tt.instance.NamedReconciler.GetName())
+			assert.NotNil(t, tt.instance.GroupReconciler)
+			assert.NotEmpty(t, tt.instance.GroupReconciler.GetGroupName())
+		})
+	}
+}
diff --git a/controllers/gitrepository/testdata/hook.json b/controllers/gitrepository/testdata/hook.json
new file mode 100644
index 0000000..5cba17c
--- /dev/null
+++ b/controllers/gitrepository/testdata/hook.json
@@ -0,0 +1,19 @@
+{
+  "id": 1,
+  "url": "https://api.github.com/repos/linuxsuren/test/hooks/1",
+  "test_url": "https://api.github.com/repos/linuxsuren/test/hooks/1/test",
+  "ping_url": "https://api.github.com/repos/linuxsuren/test/hooks/1/pings",
+  "name": "web",
+  "events": [
+    "push",
+    "pull_request"
+  ],
+  "active": true,
+  "config": {
+    "url": "http://example.com/webhook",
+    "content_type": "json",
+    "insecure_ssl": "1"
+  },
+  "updated_at": "2011-09-06T20:39:23Z",
+  "created_at": "2011-09-06T17:26:27Z"
+}
diff --git a/controllers/gitrepository/testdata/hooks.json b/controllers/gitrepository/testdata/hooks.json
new file mode 100644
index 0000000..5fe39c3
--- /dev/null
+++ b/controllers/gitrepository/testdata/hooks.json
@@ -0,0 +1,20 @@
+[
+  {
+    "id": 1,
+    "url": "https://api.github.com/repos/linuxsuren/test/hooks/1",
+    "test_url": "https://api.github.com/repos/linuxsuren/test/hooks/1/test",
+    "ping_url": "https://api.github.com/repos/linuxsuren/test/hooks/1/pings",
+    "name": "web",
+    "events": [
+      "push",
+      "pull_request"
+    ],
+    "active": true,
+    "config": {
+      "url": "http://example.com/webhook",
+      "content_type": "json"
+    },
+    "updated_at": "2011-09-06T20:39:23Z",
+    "created_at": "2011-09-06T17:26:27Z"
+  }
+]
diff --git a/controllers/gitrepository/webhook_notify_test.go b/controllers/gitrepository/webhook_notify_test.go
index f362a0d..2a4bac4 100644
--- a/controllers/gitrepository/webhook_notify_test.go
+++ b/controllers/gitrepository/webhook_notify_test.go
@@ -21,12 +21,16 @@ import (
 	"fmt"
 	"github.com/go-logr/logr"
 	"github.com/stretchr/testify/assert"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/tools/record"
+	mgrcore "kubesphere.io/devops/controllers/core"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	controllerruntime "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/log"
 	"testing"
 )
 
@@ -35,7 +39,7 @@ func TestWebhookReconciler_notifyGitRepo(t *testing.T) {
 	assert.Nil(t, err)
 
 	gitRepo := &v1alpha3.GitRepository{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Namespace: "ns",
 			Name:      "repo",
 		},
@@ -98,13 +102,13 @@ func TestWebhookReconciler_notifyGitRepos(t *testing.T) {
 	assert.Nil(t, err)
 
 	gitRepo := &v1alpha3.GitRepository{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Namespace: "ns",
 			Name:      "repo",
 		},
 	}
 	gitRepoA := &v1alpha3.GitRepository{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Namespace: "ns",
 			Name:      "repo-a",
 		},
@@ -200,3 +204,130 @@ func TestWebhookReconciler_notifyGitRepos(t *testing.T) {
 		})
 	}
 }
+
+func TestWebhookReconciler_SetupWithManager(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &WebhookReconciler{
+				Client:   tt.fields.Client,
+				log:      tt.fields.log,
+				recorder: tt.fields.recorder,
+			}
+			mgr := &mgrcore.FakeManager{
+				Client: tt.fields.Client,
+				Scheme: schema,
+			}
+			tt.wantErr(t, r.SetupWithManager(mgr), fmt.Sprintf("SetupWithManager(%v)", mgr))
+		})
+	}
+}
+
+func TestWebhookReconciler_Reconcile(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	req := controllerruntime.Request{
+		NamespacedName: types.NamespacedName{
+			Namespace: "ns",
+			Name:      "fake",
+		},
+	}
+
+	wh := &v1alpha3.Webhook{}
+	wh.Annotations = map[string]string{
+		v1alpha3.AnnotationKeyGitRepos: "fake",
+	}
+	wh.Namespace = "ns"
+	wh.Name = "fake"
+
+	repo := &v1alpha3.GitRepository{}
+	repo.Namespace = "ns"
+	repo.Name = "fake"
+
+	emptyAnnoWH := wh.DeepCopy()
+	emptyAnnoWH.Annotations = nil
+
+	type fields struct {
+		Client client.Client
+	}
+	type args struct {
+		req controllerruntime.Request
+	}
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		wantResult controllerruntime.Result
+		wantErr    assert.ErrorAssertionFunc
+	}{{
+		name:   "not found",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema)},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name:   "no desired annotation",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema, emptyAnnoWH.DeepCopy())},
+		args: args{
+			req: req,
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name:   "link to a not exit repo",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema, wh.DeepCopy())},
+		args:   args{req: req},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+		wantResult: controllerruntime.Result{Requeue: true},
+	}, {
+		name:   "normal case",
+		fields: fields{Client: fake.NewFakeClientWithScheme(schema, wh.DeepCopy(), repo.DeepCopy())},
+		args:   args{req: req},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &WebhookReconciler{
+				Client: tt.fields.Client,
+				log:    log.NullLogger{},
+			}
+			gotResult, err := r.Reconcile(tt.args.req)
+			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req)) {
+				return
+			}
+			assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req)
+		})
+	}
+}