Repository: kubesphere/ks-devops
Pull request #432 (2022-01-04 14:01:20)

Update webhooks to git repositories when they have changes
<!-- 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 documentation
/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 feature

### What this PR does / why we need it:
It should be able to update all related GitRepositories once the webhooks change.

### 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.
-->
I have tested it with the following image (built by the command `DOCKER_REPO=surenpi make docker-build-push-controller`)
```
surenpi/devops-controller:dev-v3.2.1-b88febb
```

### 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 **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/432#" title="Author self-approved">LinuxSuRen</a>*

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

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

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

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

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":[]} -->

Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 0a4166a..4da87a2 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -143,6 +143,7 @@ run:
 issues:
   exclude:
     - EXC0012
+    - SA1019
   exclude-use-default: false
 linters:
   enable:
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index f72a545..0349938 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -78,10 +78,15 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 
 	reconcilers := map[string]func(mgr manager.Manager) error{
 		"gitrepository": func(mgr manager.Manager) error {
-			return (&gitrepository.Reconciler{
+			err := (&gitrepository.Reconciler{
 				Client: mgr.GetClient(),
-				Scheme: mgr.GetScheme(),
 			}).SetupWithManager(mgr)
+			if err == nil {
+				err = (&gitrepository.WebhookReconciler{
+					Client: mgr.GetClient(),
+				}).SetupWithManager(mgr)
+			}
+			return err
 		},
 		"jenkinsconfig": func(mgr manager.Manager) error {
 			return mgr.Add(config.NewController(&config.ControllerOptions{
diff --git a/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml b/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml
index 68e5196..473a271 100644
--- a/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml
+++ b/config/crd/bases/devops.kubesphere.io_gitrepositories.yaml
@@ -38,6 +38,7 @@ spec:
           metadata:
             type: object
           spec:
+            description: GitRepositorySpec represents the desired state of a GitRepository
             properties:
               provider:
                 type: string
diff --git a/config/crd/bases/devops.kubesphere.io_webhooks.yaml b/config/crd/bases/devops.kubesphere.io_webhooks.yaml
index 102a58f..8e98949 100644
--- a/config/crd/bases/devops.kubesphere.io_webhooks.yaml
+++ b/config/crd/bases/devops.kubesphere.io_webhooks.yaml
@@ -41,6 +41,7 @@ spec:
           metadata:
             type: object
           spec:
+            description: WebhookSpec represents the desired state of a Webhook
             properties:
               events:
                 items:
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 36f5154..96885ef 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -33,6 +33,7 @@ rules:
   verbs:
   - get
   - list
+  - update
   - watch
 - apiGroups:
   - devops.kubesphere.io
@@ -74,6 +75,12 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - devops.kubesphere.io
+  resources:
+  - secrets
+  verbs:
+  - get
 - apiGroups:
   - devops.kubesphere.io
   resources:
@@ -81,3 +88,6 @@ rules:
   verbs:
   - get
   - list
+  - patch
+  - update
+  - watch
diff --git a/controllers/gitrepository/gitrepository_webhook_controller.go b/controllers/gitrepository/gitrepository_webhook_controller.go
index f4fa599..00c00dd 100644
--- a/controllers/gitrepository/gitrepository_webhook_controller.go
+++ b/controllers/gitrepository/gitrepository_webhook_controller.go
@@ -32,7 +32,6 @@ import (
 	"time"
 
 	"github.com/go-logr/logr"
-	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/tools/record"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -42,11 +41,10 @@ import (
 type Reconciler struct {
 	client.Client
 	log      logr.Logger
-	Scheme   *runtime.Scheme
 	recorder record.EventRecorder
 }
 
-//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=webhooks,verbs=get;list
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=webhooks,verbs=get;list;update;patch
 //+kubebuilder:rbac:groups=devops.kubesphere.io,resources=secrets,verbs=get
 //+kubebuilder:rbac:groups=devops.kubesphere.io,resources=gitrepositories,verbs=get;list;watch
 
@@ -73,6 +71,11 @@ func (r *Reconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error)
 		return
 	}
 
+	// make links between the webhook and git repositories
+	if err = r.linkToWebhooks(repo); err != nil {
+		return
+	}
+
 	secret := repo.Spec.Secret
 	if secret == nil {
 		result = ctrl.Result{
@@ -208,7 +211,7 @@ func (r *Reconciler) getSecret(ref *v1.SecretReference, defaultNamespace string)
 	}
 
 	if err = r.Client.Get(context.TODO(), types.NamespacedName{
-		Namespace: ns, Name: secret.Name,
+		Namespace: ns, Name: ref.Name,
 	}, secret); err != nil {
 		err = fmt.Errorf("cannot get secret %v, error is: %v", secret, err)
 	}
@@ -230,6 +233,51 @@ func getRepo(repo *v1alpha1.GitRepository) string {
 	return ""
 }
 
+func (r *Reconciler) linkToWebhooks(repo *v1alpha1.GitRepository) (err error) {
+	var failedLinks []string
+	for i := range repo.Spec.Webhooks {
+		webhookRef := repo.Spec.Webhooks[i]
+		if err = linkToWebhook(webhookRef, repo, r.Client); err != nil {
+			r.log.V(6).Info("failed to link to webhook: %v, error: %v", webhookRef, err)
+			failedLinks = append(failedLinks, webhookRef.Name)
+		}
+	}
+
+	if len(failedLinks) > 0 {
+		err = fmt.Errorf("failed to link to webhooks: %v", failedLinks)
+	}
+	return
+}
+
+func linkToWebhook(webhookRef v1.LocalObjectReference, repo *v1alpha1.GitRepository, client client.Client) (err error) {
+	webhook := &v1alpha1.Webhook{}
+	if err = client.Get(context.TODO(), types.NamespacedName{Namespace: repo.Namespace, Name: webhookRef.Name}, webhook); err != nil {
+		err = fmt.Errorf("cannot find webhook '%v', error： %v", webhookRef, err)
+		return
+	}
+
+	webhook.Annotations = addToArrayInAnnotations(webhook.Annotations, v1alpha1.AnnotationKeyGitRepos, repo.Name)
+	err = client.Update(context.TODO(), webhook)
+	return
+}
+
+func addToArrayInAnnotations(array map[string]string, key, value string) map[string]string {
+	if array == nil {
+		return map[string]string{key: value}
+	}
+
+	if val, ok := array[key]; ok {
+		if val == value || strings.Contains(val, ","+value) || strings.Contains(val, value+",") {
+			return array
+		}
+
+		array[key] = val + "," + value
+	} else {
+		array[key] = value
+	}
+	return array
+}
+
 // SetupWithManager sets up the controller with the Manager.
 func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
 	// the server should obey Kubernetes naming convention: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
diff --git a/controllers/gitrepository/webhook_notify.go b/controllers/gitrepository/webhook_notify.go
new file mode 100644
index 0000000..7393b27
--- /dev/null
+++ b/controllers/gitrepository/webhook_notify.go
@@ -0,0 +1,107 @@
+/*
+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 (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apiserver/pkg/storage/names"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"strings"
+)
+
+// WebhookReconciler notifies the GitRepositories if there are corresponding webhooks changed
+type WebhookReconciler struct {
+	client.Client
+	log      logr.Logger
+	recorder record.EventRecorder
+}
+
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=webhooks,verbs=get;list;watch
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=gitrepositories,verbs=get;update
+
+// Reconcile handles the update events of Webhook, then send the notification to a GitRepository
+func (r *WebhookReconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) {
+	ctx := context.Background()
+	log := r.log.WithValues("webhook", req.NamespacedName)
+
+	webhook := &v1alpha1.Webhook{}
+	if err = r.Client.Get(ctx, req.NamespacedName, webhook); err != nil {
+		log.Error(err, "unable to fetch webhook")
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	// skip those don't have the desired annotation
+	repos, ok := webhook.Annotations[v1alpha1.AnnotationKeyGitRepos]
+	if !ok {
+		return
+	}
+
+	if err = r.notifyGitRepos(webhook.Namespace, repos); err != nil {
+		result = ctrl.Result{
+			Requeue: true,
+		}
+	}
+	return
+}
+
+func (r *WebhookReconciler) notifyGitRepos(ns, repos string) (err error) {
+	var errs []error
+	repoArray := strings.Split(repos, ",")
+	for index := range repoArray {
+		if e := r.notifyGitRepo(ns, repoArray[index]); e != nil {
+			errs = append(errs, e)
+		}
+	}
+
+	if len(errs) > 0 {
+		err = fmt.Errorf("error happend when notifying git repos, error: %v", errs)
+	}
+	return
+}
+
+func (r *WebhookReconciler) notifyGitRepo(ns, name string) (err error) {
+	gitRepo := &v1alpha1.GitRepository{}
+
+	if err = r.Client.Get(context.TODO(), types.NamespacedName{
+		Namespace: ns,
+		Name:      name,
+	}, gitRepo); err != nil {
+		err = client.IgnoreNotFound(err)
+	}
+
+	if gitRepo.Annotations == nil {
+		gitRepo.Annotations = map[string]string{}
+	}
+	gitRepo.Annotations[v1alpha1.AnnotationKeyWebhookUpdates] = names.SimpleNameGenerator.GenerateName("")
+	err = r.Client.Update(context.TODO(), gitRepo)
+	return
+}
+
+func (r *WebhookReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.recorder = mgr.GetEventRecorderFor("webhook-notify")
+	r.log = ctrl.Log.WithName("webhook-notify")
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.Webhook{}).
+		Complete(r)
+}
diff --git a/pkg/api/devops/v1alpha1/gitrepository.go b/pkg/api/devops/v1alpha1/gitrepository.go
index 3524446..63f7ae0 100644
--- a/pkg/api/devops/v1alpha1/gitrepository.go
+++ b/pkg/api/devops/v1alpha1/gitrepository.go
@@ -5,6 +5,9 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+// AnnotationKeyWebhookUpdates is a signal that should update the webhooks
+const AnnotationKeyWebhookUpdates = "devops.kubesphere.io/webhook-updates"
+
 // +genclient
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 
diff --git a/pkg/api/devops/v1alpha1/webhook.go b/pkg/api/devops/v1alpha1/webhook.go
index 4c81721..f721646 100644
--- a/pkg/api/devops/v1alpha1/webhook.go
+++ b/pkg/api/devops/v1alpha1/webhook.go
@@ -21,6 +21,9 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+// AnnotationKeyGitRepos are the references of target git repositories
+const AnnotationKeyGitRepos = "devops.kubesphere.io/git-repositories"
+
 // +genclient
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 
diff --git a/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go b/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go
index b364f8e..98ee401 100644
--- a/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go
@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*
diff --git a/pkg/api/devops/v1alpha3/credential_types.go b/pkg/api/devops/v1alpha3/credential_types.go
index 67ed703..74fd12a 100644
--- a/pkg/api/devops/v1alpha3/credential_types.go
+++ b/pkg/api/devops/v1alpha3/credential_types.go
@@ -74,3 +74,17 @@ const (
 	CredentialSyncTimeAnnoKey   = DevOpsCredentialPrefix + "synctime"
 	CredentialSyncMsgAnnoKey    = DevOpsCredentialPrefix + "syncmsg"
 )
+
+var supportedCredentialTypes = []v1.SecretType{
+	SecretTypeBasicAuth,
+	SecretTypeSSHAuth,
+	SecretTypeSecretText,
+	SecretTypeKubeConfig,
+}
+
+// GetSupportedCredentialTypes gets all supported credential types. The return value is unmodifiable.
+func GetSupportedCredentialTypes() []v1.SecretType {
+	copiedCredentialTypes := make([]v1.SecretType, len(supportedCredentialTypes))
+	copy(copiedCredentialTypes, supportedCredentialTypes)
+	return copiedCredentialTypes
+}
diff --git a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
index 27798d0..d8ff289 100644
--- a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
+++ b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
@@ -1,3 +1,4 @@
+//go:build !ignore_autogenerated
 // +build !ignore_autogenerated
 
 /*
diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go
index 029eb11..1596749 100644
--- a/pkg/apiserver/apiserver.go
+++ b/pkg/apiserver/apiserver.go
@@ -147,11 +147,11 @@ func (s *APIServer) installKubeSphereAPIs() {
 		s.KubernetesClient,
 		jenkinsCore))
 	devopsv1alpha3.AddToContainer(s.container, s.DevopsClient, s.KubernetesClient, s.Client)
-	utilruntime.Must(oauth.AddToContainer(s.container,
+	oauth.AddToContainer(s.container,
 		auth.NewTokenOperator(
 			s.CacheClient,
 			s.Config.AuthenticationOptions),
-	))
+	)
 }
 
 func (s *APIServer) Run(stopCh <-chan struct{}) (err error) {
diff --git a/pkg/client/k8s/fake_client.go b/pkg/client/k8s/fake_client.go
index f8e1153..404ec43 100644
--- a/pkg/client/k8s/fake_client.go
+++ b/pkg/client/k8s/fake_client.go
@@ -34,21 +34,24 @@ type FakeClient struct {
 	ApiExtensionClient apiextensionsclient.Interface
 	MasterURL          string
 	KubeConfig         *rest.Config
+
+	ksClient versioned.Interface
 }
 
 func (n *FakeClient) KubeSphere() versioned.Interface {
-	panic("implement me")
+	return n.ksClient
 }
 
 func NewFakeClientSets(k8sClient kubernetes.Interface, discoveryClient *discovery.DiscoveryClient,
 	apiextensionsclient apiextensionsclient.Interface,
-	masterURL string, kubeConfig *rest.Config) Client {
+	masterURL string, kubeConfig *rest.Config, ksClient versioned.Interface) Client {
 	return &FakeClient{
 		K8sClient:          k8sClient,
 		DiscoveryClient:    discoveryClient,
 		ApiExtensionClient: apiextensionsclient,
 		MasterURL:          masterURL,
 		KubeConfig:         kubeConfig,
+		ksClient:           ksClient,
 	}
 }
 
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index e83630d..620325e 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -129,9 +129,14 @@ const (
 	NotificationTag             = "Notification"
 	NotificationSecretNamespace = "kubesphere-monitoring-federated"
 	NotificationManagedLabel    = "notification.kubesphere.io/managed"
-	K8SToken                    = "k8s.token"
 )
 
+// K8SToken is the context key of k8s token
+var K8SToken = ContextKeyK8SToken("k8s.token")
+
+// ContextKeyK8SToken represents a type alias for the context key
+type ContextKeyK8SToken string
+
 const (
 	StatusActive     = "active"
 	StatusDeleted    = "deleted"
diff --git a/pkg/kapis/devops/v1alpha3/handler.go b/pkg/kapis/devops/v1alpha3/handler.go
index 536ca22..8dc8a4f 100644
--- a/pkg/kapis/devops/v1alpha3/handler.go
+++ b/pkg/kapis/devops/v1alpha3/handler.go
@@ -321,7 +321,7 @@ func (h *devopsHandler) ListCredential(request *restful.Request, response *restf
 	devops := request.PathParameter("devops")
 	query := query.ParseQueryParameter(request)
 
-	if client, err := h.getDevOps(request); err == nil {
+	if client, err := h.getDevOps(request); err == nil && client != nil {
 		objs, err := client.ListCredentialObj(devops, query)
 		if err != nil {
 			klog.Error(err)
@@ -417,14 +417,14 @@ func (h *devopsHandler) DeleteCredential(request *restful.Request, response *res
 
 func (h *devopsHandler) getDevOps(request *restful.Request) (operator devops.DevopsOperator, err error) {
 	ctx := request.Request.Context()
-	token := ctx.Value(constants.K8SToken).(string)
+	token := ctx.Value(constants.K8SToken).(constants.ContextKeyK8SToken)
 
 	var k8sClient k8s.Client
 	if token == "" {
 		k8sClient = h.k8sClient
 	} else {
 		klog.V(9).Infof("get DevOps client with token: %s", token)
-		k8sClient, err = k8s.NewKubernetesClientWithToken(token, h.k8sClient.Config().Host)
+		k8sClient, err = k8s.NewKubernetesClientWithToken(string(token), h.k8sClient.Config().Host)
 	}
 
 	if err == nil {
diff --git a/pkg/kapis/oauth/handler.go b/pkg/kapis/oauth/handler.go
index 243ed0b..d12c843 100644
--- a/pkg/kapis/oauth/handler.go
+++ b/pkg/kapis/oauth/handler.go
@@ -65,7 +65,7 @@ func newHandler(tokenOperator auth.TokenManagementInterface) *handler {
 	return &handler{tokenOperator: tokenOperator}
 }
 
-// Implement webhook authentication interface
+// TokenReview Implement webhook authentication interface
 // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication
 func (h *handler) TokenReview(req *restful.Request, resp *restful.Response) {
 	var tokenReview TokenReview
diff --git a/pkg/kapis/oauth/register.go b/pkg/kapis/oauth/register.go
index b797964..c94acff 100644
--- a/pkg/kapis/oauth/register.go
+++ b/pkg/kapis/oauth/register.go
@@ -27,7 +27,7 @@ import (
 	"kubesphere.io/devops/pkg/models/auth"
 )
 
-func AddToContainer(c *restful.Container, tokenOperator auth.TokenManagementInterface) error {
+func AddToContainer(c *restful.Container, tokenOperator auth.TokenManagementInterface) {
 	ws := &restful.WebService{}
 	ws.Path("/oauth").
 		Consumes(restful.MIME_JSON).
@@ -45,5 +45,4 @@ func AddToContainer(c *restful.Container, tokenOperator auth.TokenManagementInte
 		Returns(http.StatusOK, api.StatusOK, TokenReview{}).
 		Metadata(restfulspec.KeyOpenAPITags, []string{constants.AuthenticationTag}))
 	c.Add(ws)
-	return nil
 }
diff --git a/pkg/models/devops/devops.go b/pkg/models/devops/devops.go
index 91246e0..24a8945 100644
--- a/pkg/models/devops/devops.go
+++ b/pkg/models/devops/devops.go
@@ -37,6 +37,7 @@ import (
 
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	devopsv1alpha3 "kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"kubesphere.io/devops/pkg/utils/secretutil"
 
 	"kubesphere.io/devops/pkg/api"
 	"kubesphere.io/devops/pkg/apiserver/query"
@@ -292,7 +293,11 @@ func (d devopsOperator) CreateCredentialObj(projectName string, secret *v1.Secre
 	secret.Annotations[devopsv1alpha3.CredentialAutoSyncAnnoKey] = "true"
 	secret.Annotations[devopsv1alpha3.CredentialSyncStatusAnnoKey] = StatusPending
 	secret.Annotations[devopsv1alpha3.CredentialSyncTimeAnnoKey] = GetSyncNowTime()
-	return d.k8sclient.CoreV1().Secrets(projectObj.Status.AdminNamespace).Create(d.context, secret, metav1.CreateOptions{})
+	if secret, err := d.k8sclient.CoreV1().Secrets(projectObj.Status.AdminNamespace).Create(d.context, secret, metav1.CreateOptions{}); err != nil {
+		return nil, err
+	} else {
+		return secretutil.MaskCredential(secret), nil
+	}
 }
 
 func (d devopsOperator) GetCredentialObj(projectName string, secretName string) (*v1.Secret, error) {
@@ -300,7 +305,13 @@ func (d devopsOperator) GetCredentialObj(projectName string, secretName string)
 	if err != nil {
 		return nil, err
 	}
-	return d.k8sclient.CoreV1().Secrets(projectObj.Status.AdminNamespace).Get(d.context, secretName, metav1.GetOptions{})
+	if secret, err := d.k8sclient.CoreV1().Secrets(projectObj.Status.AdminNamespace).Get(d.context, secretName, metav1.GetOptions{}); err != nil {
+		return nil, err
+	} else {
+		// TODO Mask the secret if there is no place to use plain secret.
+		// return secretutil.MaskCredential(secret), nil
+		return secret, nil
+	}
 }
 
 func (d devopsOperator) DeleteCredentialObj(projectName string, secret string) error {
@@ -319,7 +330,11 @@ func (d devopsOperator) UpdateCredentialObj(projectName string, secret *v1.Secre
 	secret.Annotations[devopsv1alpha3.CredentialAutoSyncAnnoKey] = "true"
 	secret.Annotations[devopsv1alpha3.CredentialSyncStatusAnnoKey] = StatusPending
 	secret.Annotations[devopsv1alpha3.CredentialSyncTimeAnnoKey] = GetSyncNowTime()
-	return d.k8sclient.CoreV1().Secrets(projectObj.Status.AdminNamespace).Update(d.context, secret, metav1.UpdateOptions{})
+	if secret, err := d.k8sclient.CoreV1().Secrets(projectObj.Status.AdminNamespace).Update(d.context, secret, metav1.UpdateOptions{}); err != nil {
+		return nil, err
+	} else {
+		return secretutil.MaskCredential(secret), nil
+	}
 }
 
 func (d devopsOperator) ListCredentialObj(projectName string, query *query.Query) (api.ListResult, error) {
@@ -335,18 +350,12 @@ func (d devopsOperator) ListCredentialObj(projectName string, query *query.Query
 	}
 	var result []runtime.Object
 
-	credentialTypeList := []v1.SecretType{
-		v1alpha3.SecretTypeBasicAuth,
-		v1alpha3.SecretTypeSSHAuth,
-		v1alpha3.SecretTypeSecretText,
-		v1alpha3.SecretTypeKubeConfig,
-	}
-	for i, _ := range credentialObjList.Items {
+	credentialTypeList := v1alpha3.GetSupportedCredentialTypes()
+	for i := range credentialObjList.Items {
 		credential := credentialObjList.Items[i]
-
 		for _, credentialType := range credentialTypeList {
 			if credential.Type == credentialType {
-				result = append(result, &credential)
+				result = append(result, secretutil.MaskCredential(&credential))
 			}
 		}
 	}
diff --git a/pkg/utils/secretutil/secret_mask.go b/pkg/utils/secretutil/secret_mask.go
new file mode 100644
index 0000000..857888b
--- /dev/null
+++ b/pkg/utils/secretutil/secret_mask.go
@@ -0,0 +1,52 @@
+package secretutil
+
+import (
+	v1 "k8s.io/api/core/v1"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+)
+
+type credentialMask func(*v1.Secret) *v1.Secret
+
+var defaultMasque = []byte("")
+var credentialMaskHolder = map[v1.SecretType]credentialMask{}
+
+func basicAuthCredentialMask(secret *v1.Secret) *v1.Secret {
+	secret.Data[v1alpha3.BasicAuthPasswordKey] = defaultMasque
+	return secret
+}
+
+func sshAuthCredentialMask(secret *v1.Secret) *v1.Secret {
+	secret.Data[v1alpha3.SSHAuthPassphraseKey] = defaultMasque
+	secret.Data[v1alpha3.SSHAuthPrivateKey] = defaultMasque
+	return secret
+}
+
+func secretTextCredentialMask(secret *v1.Secret) *v1.Secret {
+	secret.Data[v1alpha3.SecretTextSecretKey] = defaultMasque
+	return secret
+}
+
+func kubeconfigCredentialMask(secret *v1.Secret) *v1.Secret {
+	secret.Data[v1alpha3.KubeConfigSecretKey] = defaultMasque
+	return secret
+}
+
+// MaskCredential masks sensetive data inside credential.
+func MaskCredential(secret *v1.Secret) *v1.Secret {
+	if secret == nil || secret.Data == nil {
+		return secret
+	}
+	credentialMask := credentialMaskHolder[secret.Type]
+	if credentialMask != nil {
+		return credentialMask(secret)
+	}
+	return secret
+}
+
+func init() {
+	// register credential masks
+	credentialMaskHolder[v1alpha3.SecretTypeBasicAuth] = basicAuthCredentialMask
+	credentialMaskHolder[v1alpha3.SecretTypeSSHAuth] = sshAuthCredentialMask
+	credentialMaskHolder[v1alpha3.SecretTypeSecretText] = secretTextCredentialMask
+	credentialMaskHolder[v1alpha3.SecretTypeKubeConfig] = kubeconfigCredentialMask
+}


Test patch:
diff --git a/controllers/gitrepository/gitrepository_webhook_controller_test.go b/controllers/gitrepository/gitrepository_webhook_controller_test.go
index 4da9ca1..98e0fc3 100644
--- a/controllers/gitrepository/gitrepository_webhook_controller_test.go
+++ b/controllers/gitrepository/gitrepository_webhook_controller_test.go
@@ -1,8 +1,22 @@
 package gitrepository
 
 import (
+	"context"
+	"fmt"
 	"github.com/drone/go-scm/scm"
+	"github.com/drone/go-scm/scm/driver/github"
+	"github.com/go-logr/logr"
+	testing2 "github.com/go-logr/logr/testing"
+	"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"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"reflect"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"strings"
 	"testing"
 )
 
@@ -111,3 +125,511 @@ func Test_exist(t *testing.T) {
 		})
 	}
 }
+
+func Test_linkToWebhook(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	repo := &v1alpha1.GitRepository{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "test",
+			Name:      "name",
+		},
+	}
+	webhook := &v1alpha1.Webhook{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "test",
+			Name:      "webhook",
+		},
+	}
+	webhookA := &v1alpha1.Webhook{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "test",
+			Name:      "webhook-a",
+			Annotations: map[string]string{
+				v1alpha1.AnnotationKeyGitRepos: "name",
+			},
+		},
+	}
+
+	type args struct {
+		webhook v1.LocalObjectReference
+		repo    *v1alpha1.GitRepository
+		client  client.Client
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+		check   func(client client.Client) bool
+	}{{
+		name: "normal case",
+		args: args{
+			webhook: v1.LocalObjectReference{Name: "webhook"},
+			repo: &v1alpha1.GitRepository{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "test",
+					Name:      "name",
+				},
+			},
+			client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy(), webhook.DeepCopy()),
+		},
+		check: func(client client.Client) bool {
+			webh := &v1alpha1.Webhook{}
+			if err := client.Get(context.TODO(), types.NamespacedName{
+				Namespace: "test",
+				Name:      "webhook",
+			}, webh); err != nil {
+				return false
+			}
+			val := webh.Annotations[v1alpha1.AnnotationKeyGitRepos]
+			return val == "name"
+		},
+		wantErr: false,
+	}, {
+		name: "not found the desired webhook",
+		args: args{
+			webhook: v1.LocalObjectReference{Name: "not-found"},
+			repo: &v1alpha1.GitRepository{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "test",
+					Name:      "name",
+				},
+			},
+			client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy(), webhook.DeepCopy()),
+		},
+		check: func(client client.Client) bool {
+			webh := &v1alpha1.Webhook{}
+			if err := client.Get(context.TODO(), types.NamespacedName{
+				Namespace: "test",
+				Name:      "webhook",
+			}, webh); err != nil {
+				return false
+			}
+			val := webh.Annotations[v1alpha1.AnnotationKeyGitRepos]
+			return val == "name"
+		},
+		wantErr: true,
+	}, {
+		name: "has the same repo in the annotations",
+		args: args{
+			webhook: v1.LocalObjectReference{Name: "webhook-a"},
+			repo: &v1alpha1.GitRepository{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "test",
+					Name:      "name",
+				},
+			},
+			client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy(), webhookA.DeepCopy()),
+		},
+		check: func(client client.Client) (result bool) {
+			webh := &v1alpha1.Webhook{}
+			if err := client.Get(context.TODO(), types.NamespacedName{
+				Namespace: "test",
+				Name:      "webhook-a",
+			}, webh); err != nil {
+				return false
+			}
+			val := webh.Annotations[v1alpha1.AnnotationKeyGitRepos]
+			result = val == "name"
+			if !result {
+				fmt.Printf("expect 'name', the actual value is '%s'\n", val)
+			}
+			return
+		},
+		wantErr: false,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := linkToWebhook(tt.args.webhook, tt.args.repo, tt.args.client); (err != nil) != tt.wantErr {
+				t.Errorf("linkToWebhook() error = %v, wantErr %v", err, tt.wantErr)
+			} else if err == nil {
+				if !tt.check(tt.args.client) {
+					t.Errorf("failed to do the check work: %s", tt.name)
+				}
+			}
+		})
+	}
+}
+
+func Test_addToSlick(t *testing.T) {
+	type args struct {
+		array    map[string]string
+		key, val string
+	}
+	tests := []struct {
+		name string
+		args args
+		want map[string]string
+	}{{
+		name: "the map is nil",
+		args: args{
+			array: nil,
+			key:   "key",
+			val:   "val",
+		},
+		want: map[string]string{"key": "val"},
+	}, {
+		name: "the map is empty",
+		args: args{
+			array: map[string]string{},
+			key:   "key",
+			val:   "val",
+		},
+		want: map[string]string{"key": "val"},
+	}, {
+		name: "have the same item in the map",
+		args: args{
+			array: map[string]string{"key": "value"},
+			key:   "key",
+			val:   "val",
+		},
+		want: map[string]string{"key": "value,val"},
+	}, {
+		name: "have not the same item in the map",
+		args: args{
+			array: map[string]string{"key": "val"},
+			key:   "left",
+			val:   "right",
+		},
+		want: map[string]string{"key": "val", "left": "right"},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := addToArrayInAnnotations(tt.args.array, tt.args.key, tt.args.val); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("addToArrayInAnnotations() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestReconciler_linkToWebhooks(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	repo := v1alpha1.GitRepository{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "test",
+			Name:      "name",
+		},
+		Spec: v1alpha1.GitRepositorySpec{
+			Webhooks: []v1.LocalObjectReference{{
+				Name: "webhook",
+			}},
+		},
+	}
+	webhook := v1alpha1.Webhook{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "test",
+			Name:      "webhook",
+		},
+	}
+
+	type fields struct {
+		Client client.Client
+		log    logr.Logger
+	}
+	type args struct {
+		repo *v1alpha1.GitRepository
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr bool
+		check   func(client client.Client) bool
+	}{{
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy(), webhook.DeepCopy()),
+			log:    testing2.NullLogger{},
+		},
+		args: args{
+			repo: &repo,
+		},
+		wantErr: false,
+		check: func(client client.Client) bool {
+			webh := &v1alpha1.Webhook{}
+			if err := client.Get(context.TODO(), types.NamespacedName{
+				Namespace: "test",
+				Name:      "webhook",
+			}, webh); err != nil {
+				return false
+			}
+			val := webh.Annotations[v1alpha1.AnnotationKeyGitRepos]
+			return val == "name"
+		},
+	}, {
+		name: "has errors",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, repo.DeepCopy()),
+			log:    testing2.NullLogger{},
+		},
+		args: args{
+			repo: &repo,
+		},
+		wantErr: true,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				Client: tt.fields.Client,
+				log:    tt.fields.log,
+			}
+			if err := r.linkToWebhooks(tt.args.repo); (err != nil) != tt.wantErr {
+				t.Errorf("linkToWebhooks() error = %v, wantErr %v", err, tt.wantErr)
+			} else if err == nil {
+				if !tt.check(r.Client) {
+					t.Errorf("failed to do the check work: %s", tt.name)
+				}
+			}
+		})
+	}
+}
+
+func TestReconciler_getTokenFromSecret(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	basicSecret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "basicSecret",
+			Namespace: "ns",
+		},
+		Type: v1.SecretTypeBasicAuth,
+		Data: map[string][]byte{
+			v1.BasicAuthPasswordKey: []byte("token"),
+		},
+	}
+	opaqueSecret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "opaqueSecret",
+			Namespace: "ns",
+		},
+		Type: v1.SecretTypeOpaque,
+		Data: map[string][]byte{
+			v1.ServiceAccountTokenKey: []byte("token"),
+		},
+	}
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	type args struct {
+		secretRef        *v1.SecretReference
+		defaultNamespace string
+	}
+	tests := []struct {
+		name      string
+		fields    fields
+		args      args
+		wantToken string
+		wantErr   assert.ErrorAssertionFunc
+	}{{
+		name: "normal case, basic auth secret",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, basicSecret.DeepCopy()),
+		},
+		args: args{
+			secretRef: &v1.SecretReference{
+				Name:      "basicSecret",
+				Namespace: "ns",
+			},
+			defaultNamespace: "ns",
+		},
+		wantToken: "token",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return false
+		},
+	}, {
+		name: "normal case, opaque secret",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, opaqueSecret.DeepCopy()),
+		},
+		args: args{
+			secretRef: &v1.SecretReference{
+				Name:      "opaqueSecret",
+				Namespace: "ns",
+			},
+			defaultNamespace: "ns",
+		},
+		wantToken: "token",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return false
+		},
+	}, {
+		name: "normal case, no namespace in the SecretReference",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, opaqueSecret.DeepCopy()),
+		},
+		args: args{
+			secretRef: &v1.SecretReference{
+				Name: "opaqueSecret",
+			},
+			defaultNamespace: "ns",
+		},
+		wantToken: "token",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return false
+		},
+	}, {
+		name: "error case, not exist secret",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema),
+		},
+		args: args{
+			secretRef: &v1.SecretReference{
+				Name:      "opaqueSecret",
+				Namespace: "ns",
+			},
+			defaultNamespace: "ns",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(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,
+			}
+			gotToken, err := r.getTokenFromSecret(tt.args.secretRef, tt.args.defaultNamespace)
+			if !tt.wantErr(t, err, fmt.Sprintf("getTokenFromSecret(%v, %v)", tt.args.secretRef, tt.args.defaultNamespace)) {
+				return
+			}
+			assert.Equalf(t, tt.wantToken, gotToken, "getTokenFromSecret(%v, %v)", tt.args.secretRef, tt.args.defaultNamespace)
+		})
+	}
+}
+
+func TestReconciler_getGitClient(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	basicSecret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "basicSecret",
+			Namespace: "ns",
+		},
+		Type: v1.SecretTypeBasicAuth,
+		Data: map[string][]byte{
+			v1.BasicAuthPasswordKey: []byte("token"),
+		},
+	}
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	type args struct {
+		repo *v1alpha1.GitRepository
+	}
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		wantClient *scm.Client
+		wantErr    assert.ErrorAssertionFunc
+	}{{
+		name: "not support git provider",
+		args: args{
+			repo: &v1alpha1.GitRepository{
+				Spec: v1alpha1.GitRepositorySpec{
+					Provider: "not-support",
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err, i)
+			assert.Equal(t, strings.HasPrefix(err.Error(), "not support git provider: "), true, i)
+			return true
+		},
+	}, {
+		name: "no secret found",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema),
+		},
+		args: args{
+			repo: &v1alpha1.GitRepository{
+				ObjectMeta: metav1.ObjectMeta{Namespace: "ns"},
+				Spec: v1alpha1.GitRepositorySpec{
+					Provider: "github",
+					Secret: &v1.SecretReference{
+						Name: "basicSecret",
+					},
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err, i)
+			return true
+		},
+		wantClient: github.NewDefault(),
+	}, {
+		name: "github provider",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, basicSecret.DeepCopy()),
+		},
+		args: args{
+			repo: &v1alpha1.GitRepository{
+				ObjectMeta: metav1.ObjectMeta{Namespace: "ns"},
+				Spec: v1alpha1.GitRepositorySpec{
+					Provider: "github",
+					Secret: &v1.SecretReference{
+						Name: "basicSecret",
+					},
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err, i)
+			return false
+		},
+	}, {
+		name: "gitlab provider",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, basicSecret.DeepCopy()),
+		},
+		args: args{
+			repo: &v1alpha1.GitRepository{
+				ObjectMeta: metav1.ObjectMeta{Namespace: "ns"},
+				Spec: v1alpha1.GitRepositorySpec{
+					Provider: "gitlab",
+					Secret: &v1.SecretReference{
+						Name: "basicSecret",
+					},
+				},
+			},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err, i)
+			return false
+		},
+	}}
+	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,
+			}
+			gotClient, err := r.getGitClient(tt.args.repo)
+			if !tt.wantErr(t, err, fmt.Sprintf("getGitClient(%v)", tt.args.repo)) {
+				return
+			}
+			assert.Equalf(t, tt.wantClient, gotClient, "getGitClient(%v)", tt.args.repo)
+		})
+	}
+}
diff --git a/controllers/gitrepository/webhook_notify_test.go b/controllers/gitrepository/webhook_notify_test.go
new file mode 100644
index 0000000..b89983f
--- /dev/null
+++ b/controllers/gitrepository/webhook_notify_test.go
@@ -0,0 +1,186 @@
+package gitrepository
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestWebhookReconciler_notifyGitRepo(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	gitRepo := &v1alpha1.GitRepository{
+		ObjectMeta: v1.ObjectMeta{
+			Namespace: "ns",
+			Name:      "repo",
+		},
+	}
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	type args struct {
+		ns   string
+		name string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr assert.ErrorAssertionFunc
+		verify  func(assert.TestingT, client.Client)
+	}{{
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, gitRepo.DeepCopy()),
+		},
+		args: args{
+			ns:   "ns",
+			name: "repo",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err, i)
+			return false
+		},
+		verify: func(t assert.TestingT, c client.Client) {
+			repo := &v1alpha1.GitRepository{}
+			err := c.Get(context.TODO(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "repo",
+			}, repo)
+			assert.Nil(t, err)
+
+			assert.NotEmpty(t, repo.Annotations[v1alpha1.AnnotationKeyWebhookUpdates])
+		},
+	}}
+	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,
+			}
+			tt.wantErr(t, r.notifyGitRepo(tt.args.ns, tt.args.name), fmt.Sprintf("notifyGitRepo(%v, %v)", tt.args.ns, tt.args.name))
+			tt.verify(t, tt.fields.Client)
+		})
+	}
+}
+
+func TestWebhookReconciler_notifyGitRepos(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	gitRepo := &v1alpha1.GitRepository{
+		ObjectMeta: v1.ObjectMeta{
+			Namespace: "ns",
+			Name:      "repo",
+		},
+	}
+	gitRepoA := &v1alpha1.GitRepository{
+		ObjectMeta: v1.ObjectMeta{
+			Namespace: "ns",
+			Name:      "repo-a",
+		},
+	}
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	type args struct {
+		ns    string
+		repos string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr assert.ErrorAssertionFunc
+		verify  func(assert.TestingT, client.Client)
+	}{{
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema,
+				gitRepo.DeepCopy(), gitRepoA.DeepCopy()),
+		},
+		args: args{
+			ns:    "ns",
+			repos: "repo,repo-a",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err, i)
+			return false
+		},
+		verify: func(t assert.TestingT, c client.Client) {
+			repo1 := &v1alpha1.GitRepository{}
+			err := c.Get(context.TODO(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "repo",
+			}, repo1)
+			assert.Nil(t, err)
+			assert.NotEmpty(t, repo1.Annotations[v1alpha1.AnnotationKeyWebhookUpdates])
+
+			repo2 := &v1alpha1.GitRepository{}
+			err = c.Get(context.TODO(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "repo-a",
+			}, repo2)
+			assert.Nil(t, err)
+			assert.NotEmpty(t, repo2.Annotations[v1alpha1.AnnotationKeyWebhookUpdates])
+		},
+	}, {
+		name: "has errors",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema,
+				gitRepo.DeepCopy(), gitRepoA.DeepCopy()),
+		},
+		args: args{
+			ns:    "ns",
+			repos: "repo,repo-a,repo-b",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err, i)
+			return true
+		},
+		verify: func(t assert.TestingT, c client.Client) {
+			repo1 := &v1alpha1.GitRepository{}
+			err := c.Get(context.TODO(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "repo",
+			}, repo1)
+			assert.Nil(t, err)
+			assert.NotEmpty(t, repo1.Annotations[v1alpha1.AnnotationKeyWebhookUpdates])
+
+			repo2 := &v1alpha1.GitRepository{}
+			err = c.Get(context.TODO(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "repo-a",
+			}, repo2)
+			assert.Nil(t, err)
+			assert.NotEmpty(t, repo2.Annotations[v1alpha1.AnnotationKeyWebhookUpdates])
+		},
+	}}
+	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,
+			}
+			tt.wantErr(t, r.notifyGitRepos(tt.args.ns, tt.args.repos), fmt.Sprintf("notifyGitRepos(%v, %v)", tt.args.ns, tt.args.repos))
+			tt.verify(t, tt.fields.Client)
+		})
+	}
+}
diff --git a/pkg/api/devops/v1alpha3/credential_types_test.go b/pkg/api/devops/v1alpha3/credential_types_test.go
new file mode 100644
index 0000000..ae213ef
--- /dev/null
+++ b/pkg/api/devops/v1alpha3/credential_types_test.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2020 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 v1alpha3
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetSupportedCredentialTypes(t *testing.T) {
+	// test basic return
+	types := GetSupportedCredentialTypes()
+	assert.Equal(t, supportedCredentialTypes, types)
+
+	// test unmodifiable
+	// try to modify the types
+	types = append(types, SecretTypeBasicAuth)
+	// check if the return value is modified
+	assert.NotEqual(t, types, GetSupportedCredentialTypes())
+	assert.Equal(t, supportedCredentialTypes, GetSupportedCredentialTypes())
+}
diff --git a/pkg/kapis/devops/v1alpha2/register_test.go b/pkg/kapis/devops/v1alpha2/register_test.go
new file mode 100644
index 0000000..fb8a4cc
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha2/register_test.go
@@ -0,0 +1,206 @@
+package v1alpha2
+
+import (
+	"context"
+	"github.com/emicklei/go-restful"
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	k8sfake "k8s.io/client-go/kubernetes/fake"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	fakeclientset "kubesphere.io/devops/pkg/client/clientset/versioned/fake"
+	fakedevops "kubesphere.io/devops/pkg/client/devops/fake"
+	"kubesphere.io/devops/pkg/client/k8s"
+	"kubesphere.io/devops/pkg/constants"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestAPIsExist(t *testing.T) {
+	httpWriter := httptest.NewRecorder()
+
+	err := AddToContainer(restful.DefaultContainer, nil, fakedevops.NewFakeDevops(nil),
+		nil,
+		fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
+			ObjectMeta: metav1.ObjectMeta{Name: "fake"},
+		}), nil, "", k8s.NewFakeClientSets(k8sfake.NewSimpleClientset(), nil, nil, "", nil,
+			fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
+				ObjectMeta: metav1.ObjectMeta{Name: "fake"},
+			})), core.JenkinsCore{})
+	assert.Nil(t, err)
+
+	type args struct {
+		method string
+		uri    string
+	}
+	tests := []struct {
+		name string
+		args args
+	}{{
+		name: "credential usage",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/credentials/fake/usage",
+		},
+	}, {
+		name: "get a pipeline",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake",
+		},
+	}, {
+		name: "search API",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/search",
+		},
+	}, {
+		name: "get a pipeline run",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/runs/fake",
+		},
+	}, {
+		name: "get a pipeline list",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/runs",
+		},
+	}, {
+		name: "stop a pipeline run",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/pipelines/fake/runs/fake/stop",
+		},
+	}, {
+		name: "replay a pipeline run",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/pipelines/fake/runs/fake/replay",
+		},
+	}, {
+		name: "start a pipeline run",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/pipelines/fake/runs",
+		},
+	}, {
+		name: "get artifacts from a pipeline run",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/runs/fake/artifacts",
+		},
+	}, {
+		name: "get log output from a pipeline run",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/runs/fake/log",
+		},
+	}, {
+		name: "get log output from a pipeline run step",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/runs/fake/nodes/fake/steps/fake/log",
+		},
+	}, {
+		name: "get branches from a pipeline",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/branches",
+		},
+	}, {
+		name: "scan a pipeline",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/pipelines/fake/scan",
+		},
+	}, {
+		name: "get consolelog from a pipeline",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake/consolelog",
+		},
+	}, {
+		name: "get crumb issuer",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/crumbissuer",
+		},
+	}, {
+		name: "get servers",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/scms/fake/servers",
+		},
+	}, {
+		name: "get organizations",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/scms/fake/organizations",
+		},
+	}, {
+		name: "get repositories",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/scms/fake/organizations/fake/repositories",
+		},
+	}, {
+		name: "scm verify",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/scms/fake/verify",
+		},
+	}, {
+		name: "webhook-git",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/webhook/git",
+		},
+	}, {
+		name: "webhook-git",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/webhook/git",
+		},
+	}, {
+		name: "webhook-github",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/webhook/github",
+		},
+	}, {
+		name: "generic-trigger",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/webhook/generic-trigger",
+		},
+	}, {
+		name: "checkScriptCompile",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/pipelines/fake/checkScriptCompile",
+		},
+	}, {
+		name: "checkCron",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/checkCron",
+		},
+	}, {
+		name: "tojenkinsfile",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/tojenkinsfile",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpRequest, _ := http.NewRequest(tt.args.method,
+				"http://fake.com/kapis/devops.kubesphere.io/v1alpha2"+tt.args.uri, nil)
+			httpRequest = httpRequest.WithContext(context.WithValue(context.TODO(), constants.K8SToken, constants.ContextKeyK8SToken("")))
+			restful.DefaultContainer.Dispatch(httpWriter, httpRequest)
+			assert.NotEqual(t, httpWriter.Code, 404)
+		})
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipeline/register_test.go b/pkg/kapis/devops/v1alpha3/pipeline/register_test.go
new file mode 100644
index 0000000..3a1b374
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha3/pipeline/register_test.go
@@ -0,0 +1,52 @@
+package pipeline
+
+import (
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/apiserver/runtime"
+	"net/http"
+	"net/http/httptest"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestAPIsExist(t *testing.T) {
+	httpWriter := httptest.NewRecorder()
+	wsWithGroup := runtime.NewWebService(v1alpha1.GroupVersion)
+
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	RegisterRoutes(wsWithGroup, fake.NewFakeClientWithScheme(schema))
+	restful.DefaultContainer.Add(wsWithGroup)
+
+	type args struct {
+		method string
+		uri    string
+	}
+	tests := []struct {
+		name string
+		args args
+	}{{
+		name: "get branches from the pipeline",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/namespaces/fake/pipelines/fake/branches",
+		},
+	}, {
+		name: "get a branch from the pipeline",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/namespaces/fake/pipelines/fake/branches/fake",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpRequest, _ := http.NewRequest(tt.args.method,
+				"http://fake.com/kapis/devops.kubesphere.io/v1alpha1"+tt.args.uri, nil)
+			restful.DefaultContainer.Dispatch(httpWriter, httpRequest)
+			assert.NotEqual(t, httpWriter.Code, 404)
+		})
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go
new file mode 100644
index 0000000..ee62871
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/register_test.go
@@ -0,0 +1,64 @@
+package pipelinerun
+
+import (
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/apiserver/runtime"
+	"net/http"
+	"net/http/httptest"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestAPIsExist(t *testing.T) {
+	httpWriter := httptest.NewRecorder()
+	wsWithGroup := runtime.NewWebService(v1alpha1.GroupVersion)
+
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	RegisterRoutes(wsWithGroup, fake.NewFakeClientWithScheme(schema))
+	restful.DefaultContainer.Add(wsWithGroup)
+
+	type args struct {
+		method string
+		uri    string
+	}
+	tests := []struct {
+		name string
+		args args
+	}{{
+		name: "pipelinerun list",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/namespaces/fake/pipelines/fake/pipelineruns",
+		},
+	}, {
+		name: "create a pipelinerun",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/namespaces/fake/pipelines/fake/pipelineruns",
+		},
+	}, {
+		name: "get a pipelinerun",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/namespaces/fake/pipelineruns/fake",
+		},
+	}, {
+		name: "get node details",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/namespaces/fake/pipelineruns/fake/nodedetails",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpRequest, _ := http.NewRequest(tt.args.method,
+				"http://fake.com/kapis/devops.kubesphere.io/v1alpha1"+tt.args.uri, nil)
+			restful.DefaultContainer.Dispatch(httpWriter, httpRequest)
+			assert.NotEqual(t, httpWriter.Code, 404)
+		})
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha3/register_test.go b/pkg/kapis/devops/v1alpha3/register_test.go
new file mode 100644
index 0000000..32d819a
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha3/register_test.go
@@ -0,0 +1,140 @@
+package v1alpha3
+
+import (
+	"context"
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	k8sfake "k8s.io/client-go/kubernetes/fake"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	fakeclientset "kubesphere.io/devops/pkg/client/clientset/versioned/fake"
+	fakedevops "kubesphere.io/devops/pkg/client/devops/fake"
+	"kubesphere.io/devops/pkg/client/k8s"
+	"kubesphere.io/devops/pkg/constants"
+	"net/http"
+	"net/http/httptest"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestAPIsExist(t *testing.T) {
+	httpWriter := httptest.NewRecorder()
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	AddToContainer(restful.DefaultContainer, fakedevops.NewFakeDevops(nil),
+		k8s.NewFakeClientSets(k8sfake.NewSimpleClientset(), nil, nil, "", nil,
+			fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
+				ObjectMeta: metav1.ObjectMeta{Name: "fake"},
+			})),
+		fake.NewFakeClientWithScheme(schema))
+
+	type args struct {
+		method string
+		uri    string
+	}
+	tests := []struct {
+		name string
+		args args
+	}{{
+		name: "credential list",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/credentials",
+		},
+	}, {
+		name: "create a credential",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/credentials",
+		},
+	}, {
+		name: "get a credential",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/credentials/fake",
+		},
+	}, {
+		name: "update a credential",
+		args: args{
+			method: http.MethodPut,
+			uri:    "/devops/fake/credentials/fake",
+		},
+	}, {
+		name: "delete a credential",
+		args: args{
+			method: http.MethodDelete,
+			uri:    "/devops/fake/credentials/fake",
+		},
+	}, {
+		name: "get pipeline list",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines",
+		},
+	}, {
+		name: "create a pipeline",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/devops/fake/pipelines",
+		},
+	}, {
+		name: "get a pipeline",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/devops/fake/pipelines/fake",
+		},
+	}, {
+		name: "update a pipeline",
+		args: args{
+			method: http.MethodPut,
+			uri:    "/devops/fake/pipelines/fake",
+		},
+	}, {
+		name: "delete a pipeline",
+		args: args{
+			method: http.MethodDelete,
+			uri:    "/devops/fake/pipelines/fake",
+		},
+	}, {
+		name: "get devops list",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/workspaces/fake/devops",
+		},
+	}, {
+		name: "create a devops",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/workspaces/fake/devops",
+		},
+	}, {
+		name: "get a devops",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/workspaces/fake/devops/fake",
+		},
+	}, {
+		name: "update a devops",
+		args: args{
+			method: http.MethodPut,
+			uri:    "/workspaces/fake/devops/fake",
+		},
+	}, {
+		name: "delete a devops",
+		args: args{
+			method: http.MethodDelete,
+			uri:    "/workspaces/fake/devops/fake",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpRequest, _ := http.NewRequest(tt.args.method,
+				"http://fake.com/kapis/devops.kubesphere.io/v1alpha3"+tt.args.uri, nil)
+			httpRequest = httpRequest.WithContext(context.WithValue(context.TODO(), constants.K8SToken, constants.ContextKeyK8SToken("")))
+			restful.DefaultContainer.Dispatch(httpWriter, httpRequest)
+			assert.NotEqual(t, httpWriter.Code, 404)
+		})
+	}
+}
diff --git a/pkg/kapis/oauth/register_test.go b/pkg/kapis/oauth/register_test.go
new file mode 100644
index 0000000..d6c8595
--- /dev/null
+++ b/pkg/kapis/oauth/register_test.go
@@ -0,0 +1,40 @@
+package oauth
+
+import (
+	"bytes"
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestAPIsExist(t *testing.T) {
+	httpWriter := httptest.NewRecorder()
+
+	AddToContainer(restful.DefaultContainer, nil)
+
+	type args struct {
+		method string
+		uri    string
+	}
+	tests := []struct {
+		name string
+		args args
+	}{{
+		name: "authenticate",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/authenticate",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			httpRequest, _ := http.NewRequest(tt.args.method,
+				"http://fake.com/oauth"+tt.args.uri, bytes.NewBuffer([]byte("{}")))
+			httpRequest.Header.Set("Content-Type", "application/json")
+			restful.DefaultContainer.Dispatch(httpWriter, httpRequest)
+			assert.NotEqual(t, httpWriter.Code, 404)
+		})
+	}
+}
diff --git a/pkg/utils/secretutil/secret_mask_test.go b/pkg/utils/secretutil/secret_mask_test.go
new file mode 100644
index 0000000..192e35e
--- /dev/null
+++ b/pkg/utils/secretutil/secret_mask_test.go
@@ -0,0 +1,120 @@
+package secretutil
+
+import (
+	"reflect"
+	"testing"
+
+	v1 "k8s.io/api/core/v1"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+)
+
+func Test_maskCredential(t *testing.T) {
+	type args struct {
+		secret *v1.Secret
+	}
+	tests := []struct {
+		name string
+		args args
+		want *v1.Secret
+	}{{
+		name: "Mask basic auth secret",
+		args: args{
+			secret: &v1.Secret{
+				Type: v1alpha3.SecretTypeBasicAuth,
+				Data: map[string][]byte{
+					v1alpha3.BasicAuthPasswordKey: []byte("fake password"),
+					v1alpha3.BasicAuthUsernameKey: []byte("fake username"),
+				},
+			},
+		},
+		want: &v1.Secret{
+			Type: v1alpha3.SecretTypeBasicAuth,
+			Data: map[string][]byte{
+				v1alpha3.BasicAuthPasswordKey: []byte(""),
+				v1alpha3.BasicAuthUsernameKey: []byte("fake username"),
+			},
+		},
+	}, {
+		name: "Mask ssh auth secret",
+		args: args{
+			secret: &v1.Secret{
+				Type: v1alpha3.SecretTypeSSHAuth,
+				Data: map[string][]byte{
+					v1alpha3.SSHAuthPassphraseKey: []byte("fake password"),
+					v1alpha3.SSHAuthPrivateKey:    []byte("fake private key"),
+					v1alpha3.SSHAuthUsernameKey:   []byte("fake username"),
+				},
+			},
+		},
+		want: &v1.Secret{
+			Type: v1alpha3.SecretTypeSSHAuth,
+			Data: map[string][]byte{
+				v1alpha3.SSHAuthPassphraseKey: []byte(""),
+				v1alpha3.SSHAuthPrivateKey:    []byte(""),
+				v1alpha3.SSHAuthUsernameKey:   []byte("fake username"),
+			},
+		},
+	}, {
+		name: "Mask secret text secret",
+		args: args{
+			secret: &v1.Secret{
+				Type: v1alpha3.SecretTypeSecretText,
+				Data: map[string][]byte{
+					v1alpha3.SecretTextSecretKey: []byte("fake secret text"),
+				},
+			},
+		},
+		want: &v1.Secret{
+			Type: v1alpha3.SecretTypeSecretText,
+			Data: map[string][]byte{
+				v1alpha3.SecretTextSecretKey: []byte(""),
+			},
+		},
+	}, {
+		name: "Mask kubeconfig secret",
+		args: args{
+			secret: &v1.Secret{
+				Type: v1alpha3.SecretTypeKubeConfig,
+				Data: map[string][]byte{
+					v1alpha3.KubeConfigSecretKey: []byte("fake kubeconfig"),
+				},
+			},
+		},
+		want: &v1.Secret{
+			Type: v1alpha3.SecretTypeKubeConfig,
+			Data: map[string][]byte{
+				v1alpha3.KubeConfigSecretKey: []byte(""),
+			},
+		},
+	}, {
+		name: "Nil secret",
+		args: args{
+			secret: nil,
+		},
+		want: nil,
+	}, {
+		name: "Other secret",
+		args: args{
+			secret: &v1.Secret{
+				Type: v1.SecretType("fake-type"),
+				Data: map[string][]byte{
+					"fake_key": []byte("fake_value"),
+				},
+			},
+		},
+		want: &v1.Secret{
+			Type: v1.SecretType("fake-type"),
+			Data: map[string][]byte{
+				"fake_key": []byte("fake_value"),
+			},
+		},
+	},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := MaskCredential(tt.args.secret); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("maskCredential() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}