Repository: kubesphere/ks-devops
Pull request #603 (2022-05-30 07:38:32)

Add a draft desgin of the pipeline steps
<!-- 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 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:
Read the details in the document.

### 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
-->
see #602

### 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
Support pipeline step template
```


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/.github/workflows/backup.yaml b/.github/workflows/backup.yaml
index 711eae0..667b4d6 100644
--- a/.github/workflows/backup.yaml
+++ b/.github/workflows/backup.yaml
@@ -12,7 +12,7 @@ jobs:
     steps:
       - uses: actions/checkout@v2
       - name: backup
-        uses: jenkins-zh/git-backup-actions@v0.0.4
+        uses: jenkins-zh/git-backup-actions@v0.0.6
         env:
           GIT_DEPLOY_KEY: ${{ secrets.GIT_DEPLOY_KEY }}
           TARGET_GIT: "git@gitee.com:kubesphere/ks-devops.git"
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index 1c9e303..b51b6c7 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -74,7 +74,7 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 	}).SetupWithManager(mgr); err != nil {
 		return err
 	}
-	reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, jenkinsCore, s3Client, s)
+	reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, s)
 
 	// Add all controllers into manager.
 	for name, ok := range s.FeatureOptions.GetControllers() {
@@ -93,8 +93,7 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 }
 
 func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory informers.InformerFactory,
-	devopsClient devops.Interface, jenkinsCore core.JenkinsCore, s3Client s3.Interface,
-	s *options.DevOpsControllerManagerOptions) map[string]func(mgr manager.Manager) error {
+	devopsClient devops.Interface, s *options.DevOpsControllerManagerOptions) map[string]func(mgr manager.Manager) error {
 
 	argocdReconciler := &argocd.Reconciler{
 		Client:        mgr.GetClient(),
diff --git a/docs/customize-pipeline-steps.md b/docs/customize-pipeline-steps.md
new file mode 100644
index 0000000..6193714
--- /dev/null
+++ b/docs/customize-pipeline-steps.md
@@ -0,0 +1,75 @@
+## Background
+Currently(before v3.3.0), [all Pipeline step UI components](https://github.com/kubesphere/console/tree/master/src/pages/devops/components/Pipeline/StepModals) belong to `.jsx` source file. So, it's not easy to add a new Pipeline step UI component if a contributor is not familiar with the relevant information.
+
+A second big problem with the current pattern is that some steps (or commands) might be difficult for some newcomers. In some cases, some steps need a nested step. For example:
+
+```groovy
+container('base'){
+  sh 'kubectl get ns'
+}
+
+## CRD
+Here is a sample of the step template:
+
+```yaml
+apiVersion: devops.kubesphere.io/v1alpha3
+kind: TaskTemplate
+metadata:
+  name: docker-login
+  annotations:
+    devops.kubesphere.io/step-name-zh: "Docker 登录"
+    devops.kubesphere.io/step-description-zh: "Docker 登录"
+spec:
+  name: 'Docker login'
+  description: 'Docker login'
+  secret:
+    type: basic-auth
+    wrap: true
+    mapping:                           # this is a map, the following key-values are default
+      passwordVariable: PASSWORDVARIABLE
+      usernameVariable: USERNAMEVARIABLE
+      variable: VARIABLE
+      sshUserPrivateKey: SSHUSERPRIVATEKEY
+      keyFileVariable: KEYFILEVARIABLE
+      passphraseVariable: PASSPHRASEVARIABLE
+  container: 'base'
+  runtime: 'shell|dsl'
+  template: |
+    docker login -u $username xx -p $password {{.param.registry}}
+  parameters:
+  - name: registry
+    required: true
+    display: Image Registry address
+    defaultValue: docker.io
+```
+
+The result looks like:
+```groovy
+withCredential[usernamePassword(credentialsId : "$ID" ,passwordVariable : 'PASSWD' ,usernameVariable : 'USER' ,)]) {
+  conatiner('base'){
+    sh '''
+      docker login -u $USER -p $PASSWD docker.io
+    '''
+  }
+}
+```
+
+## How to use
+API request sample:
+```shell
+curl http://ip:port/pipeline/steps/render -X POST \
+  -d '{"message": "demo message", "secret": "secret-id"}'
+```
+
+Response sample:
+```groovy
+container('base') {
+  withCredentials([string(credentialsId : 'feishu' ,variable : 'ID' ,)]) {
+    sh '''
+      curl -X POST -H "Content-Type: application/json" \
+      -d '{"msg_type":"text","content":{"text":"request example"}}' \
+      https://open.feishu.cn/open-apis/bot/v2/hook/$ID
+    '''
+  }
+}
+```
diff --git a/docs/faq.md b/docs/faq.md
new file mode 100644
index 0000000..ee97085
--- /dev/null
+++ b/docs/faq.md
@@ -0,0 +1,9 @@
+Please feel free to checkout the following frequently asked questions, hopefully this document could help you.
+
+## Pipeline cannot be triggered
+Effected versions: `v3.2.1`
+
+| Possible reason | Potential solution |
+|---|---|
+| The account was not synced due to the LDAP issues | Reset your account's password |
+| The token of Jenkins is incorrect | Restart the deployment `devops-controller` and `devops-apiserver` if you didn't change the token manually |
diff --git a/docs/webhook.md b/docs/webhook.md
index f918d0e..82bad54 100644
--- a/docs/webhook.md
+++ b/docs/webhook.md
@@ -5,7 +5,31 @@ There are several ways to trigger Pipeline via [webhook](https://en.wikipedia.or
 
 ## SCM Webhook
 
-TODO
+Supported SCM providers:
+* GitHub
+* Gitlab
+* Bitbucket
+
+There are two types of Jenkins based Pipelines: regular or multi-branch Pipeline. When a SCM webhook request received,
+the server will search all Pipelines by the Git URL, then trigger the scan action if it's a multi-branch Pipeline,
+or create a new PipelineRun if there is an annotation key-value likes the following one:
+```
+scm.devops.kubesphere.io=https=https://github.com/linuxsuren/tools
+```
+
+In case you only want some Pipelines to be triggered when specific branches changed. You can add an annotation:
+```
+scm.devops.kubesphere.io/ref='["master","fea-.*"]'
+```
+
+The webhook address is:
+```
+http://ip:port/v1alpha3/webhooks/scm
+```
+
+### Using webhook locally
+
+It's also possible to use webhook feature locally. You just need to start a proyx with [ngrok](https://ngrok.com/).
 
 ## Automatic webhook
 
diff --git a/pkg/api/devops/v1alpha3/last_changes.go b/pkg/api/devops/v1alpha3/last_changes.go
new file mode 100644
index 0000000..74f0bc4
--- /dev/null
+++ b/pkg/api/devops/v1alpha3/last_changes.go
@@ -0,0 +1,47 @@
+// Copyright 2022 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 "encoding/json"
+
+// +kubebuilder:object:generate=false
+
+// LastChanges represents a set of last SCM changes
+type LastChanges map[string]string
+
+// GetLastChanges returns the last changes
+func GetLastChanges(jsonText string) (lastChange LastChanges, err error) {
+	lastChange = map[string]string{}
+	err = json.Unmarshal([]byte(jsonText), &lastChange)
+	return
+}
+
+// Update updates hash by ref
+func (l LastChanges) Update(ref, hash string) LastChanges {
+	l[ref] = hash
+	return l
+}
+
+// LastHash return last hash value
+func (l LastChanges) LastHash(ref string) (hash string) {
+	return l[ref]
+}
+
+// String returns the string JSON format
+func (l LastChanges) String() string {
+	data, _ := json.Marshal(l)
+	return string(data)
+}
diff --git a/pkg/api/devops/v1alpha3/pipeline_types.go b/pkg/api/devops/v1alpha3/pipeline_types.go
index 657fce3..e8c8df2 100644
--- a/pkg/api/devops/v1alpha3/pipeline_types.go
+++ b/pkg/api/devops/v1alpha3/pipeline_types.go
@@ -17,6 +17,7 @@ limitations under the License.
 package v1alpha3
 
 import (
+	"fmt"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -27,13 +28,13 @@ const PipelineFinalizerName = "pipeline.finalizers.kubesphere.io"
 
 const (
 	ResourceKindPipeline      = "Pipeline"
-	ResourceSingularPipeline  = "pipeline"
 	ResourcePluralPipeline    = "pipelines"
 	PipelinePrefix            = "pipeline.devops.kubesphere.io/"
 	PipelineSpecHash          = PipelinePrefix + "spechash"
 	PipelineSyncStatusAnnoKey = PipelinePrefix + "syncstatus"
 	PipelineSyncTimeAnnoKey   = PipelinePrefix + "synctime"
 	PipelineSyncMsgAnnoKey    = PipelinePrefix + "syncmsg"
+	PipelineLastChanges       = PipelinePrefix + "last-changes"
 	// PipelineJenkinsMetadataAnnoKey is the annotation key of Jenkins Pipeline data.
 	PipelineJenkinsMetadataAnnoKey = PipelinePrefix + "jenkins-metadata"
 	// PipelineJenkinsBranchesAnnoKey is the annotation key of Jenkins Pipeline branches.
@@ -132,6 +133,28 @@ type MultiBranchPipeline struct {
 	MultiBranchJobTrigger *MultiBranchJobTrigger `json:"multibranch_job_trigger,omitempty" mapstructure:"multibranch_job_trigger" description:"Pipeline tasks that need to be triggered when branch creation/deletion"`
 }
 
+func (b *MultiBranchPipeline) GetGitURL() string {
+	switch b.SourceType {
+	case SourceTypeGit:
+		if b.GitSource != nil {
+			return b.GitSource.Url
+		}
+	case SourceTypeGithub:
+		if b.GitHubSource != nil {
+			return fmt.Sprintf("https://github.com/%s/%s", b.GitHubSource.Owner, b.GitHubSource.Repo)
+		}
+	case SourceTypeGitlab:
+		if b.GitlabSource != nil {
+			return fmt.Sprintf("https://gitlab.com/%s/%s", b.GitlabSource.Owner, b.GitlabSource.Repo)
+		}
+	case SourceTypeBitbucket:
+		if b.BitbucketServerSource != nil {
+			return fmt.Sprintf("https://bitbucket.org/%s/%s", b.BitbucketServerSource.Owner, b.BitbucketServerSource.Repo)
+		}
+	}
+	return ""
+}
+
 type GitSource struct {
 	ScmId            string          `json:"scm_id,omitempty" description:"uid of scm"`
 	Url              string          `json:"url,omitempty" mapstructure:"url" description:"url of git source"`
diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go
index dfb5bd5..ad9d978 100644
--- a/pkg/apiserver/apiserver.go
+++ b/pkg/apiserver/apiserver.go
@@ -20,6 +20,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"kubesphere.io/devops/pkg/kapis/common"
 	"kubesphere.io/devops/pkg/kapis/doc"
 	gitops "kubesphere.io/devops/pkg/kapis/gitops/v1alpha1"
@@ -141,6 +142,7 @@ func (s *APIServer) installKubeSphereAPIs() {
 	}
 
 	var wss []*restful.WebService
+	tokenIssue := getTokenIssue(s.Config)
 
 	v1alpha2WSS, err := devopsv1alpha2.AddToContainer(s.container,
 		s.InformerFactory.KubeSphereSharedInformerFactory(),
@@ -153,7 +155,7 @@ func (s *APIServer) installKubeSphereAPIs() {
 		jenkinsCore)
 	utilruntime.Must(err)
 	wss = append(wss, v1alpha2WSS...)
-	wss = append(wss, devopsv1alpha3.AddToContainer(s.container, s.DevopsClient, s.KubernetesClient, s.Client)...)
+	wss = append(wss, devopsv1alpha3.AddToContainer(s.container, s.DevopsClient, s.KubernetesClient, s.Client, tokenIssue, jenkinsCore)...)
 	wss = append(wss, oauth.AddToContainer(s.container,
 		auth.NewTokenOperator(
 			s.CacheClient,
@@ -165,6 +167,10 @@ func (s *APIServer) installKubeSphereAPIs() {
 	doc.AddSwaggerService(wss, s.container)
 }
 
+func getTokenIssue(config *apiserverconfig.Config) token.Issuer {
+	return token.NewTokenIssuer(config.AuthenticationOptions.JwtSecret, config.AuthenticationOptions.MaximumClockSkew)
+}
+
 func (s *APIServer) Run(stopCh <-chan struct{}) (err error) {
 	if err := indexers.CreatePipelineRunSCMRefNameIndexer(s.RuntimeCache); err != nil {
 		return err
diff --git a/pkg/jwt/token/fake_issuer.go b/pkg/jwt/token/fake_issuer.go
new file mode 100644
index 0000000..8e61704
--- /dev/null
+++ b/pkg/jwt/token/fake_issuer.go
@@ -0,0 +1,45 @@
+/*
+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 token
+
+import (
+	"time"
+
+	"k8s.io/apiserver/pkg/authentication/user"
+)
+
+// FakeIssuer is a fake issue for the test purpose
+type FakeIssuer struct {
+	Token        string
+	IssueToError error
+	VerifyError  error
+}
+
+// IssueTo is a fake function
+func (f *FakeIssuer) IssueTo(user user.Info, tokenType TokenType, expiresIn time.Duration) (string, error) {
+	return f.Token, f.IssueToError
+}
+
+// Verify verifies a token, and return a user info if it's a valid token, otherwise return error
+func (f *FakeIssuer) Verify(string) (user.Info, TokenType, error) {
+	return &user.DefaultInfo{}, "", f.VerifyError
+}
+
+// VerifyWithoutClaimsValidation verifies a token, but skip the claims validation
+func (f *FakeIssuer) VerifyWithoutClaimsValidation(token string) (user.Info, TokenType, error) {
+	return f.Verify(token)
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/util.go b/pkg/kapis/devops/v1alpha3/pipelinerun/util.go
index d777054..99251b4 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/util.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/util.go
@@ -19,6 +19,7 @@ package pipelinerun
 import (
 	"errors"
 	"fmt"
+	"strings"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -68,6 +69,8 @@ func convertParameters(payload *devops.RunPayload) []v1alpha3.Parameter {
 
 // CreateScm creates SCM for multi-branch Pipeline.
 func CreateScm(ps *v1alpha3.PipelineSpec, branch string) (*v1alpha3.SCM, error) {
+	branch = strings.TrimPrefix(branch, "refs/heads/")
+
 	var scm *v1alpha3.SCM
 	if ps.Type == v1alpha3.MultiBranchPipelineType {
 		if branch == "" {
diff --git a/pkg/kapis/devops/v1alpha3/register.go b/pkg/kapis/devops/v1alpha3/register.go
index d5fda16..23041ac 100644
--- a/pkg/kapis/devops/v1alpha3/register.go
+++ b/pkg/kapis/devops/v1alpha3/register.go
@@ -19,6 +19,8 @@
 package v1alpha3
 
 import (
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"net/http"
 
 	"github.com/emicklei/go-restful"
@@ -52,8 +54,8 @@ import (
 var GroupVersion = schema.GroupVersion{Group: api.GroupName, Version: "v1alpha3"}
 
 // AddToContainer adds web service into container.
-func AddToContainer(container *restful.Container, devopsClient devopsClient.Interface,
-	k8sClient k8s.Client, client client.Client) (wss []*restful.WebService) {
+func AddToContainer(container *restful.Container, devopsClient devopsClient.Interface, k8sClient k8s.Client,
+	client client.Client, tokenIssue token.Issuer, jenkins core.JenkinsCore) (wss []*restful.WebService) {
 
 	services := []*restful.WebService{
 		runtime.NewWebService(v1alpha3.GroupVersion),
@@ -67,7 +69,7 @@ func AddToContainer(container *restful.Container, devopsClient devopsClient.Inte
 		template.RegisterRoutes(service, &common.Options{
 			GenericClient: client,
 		})
-		webhook.RegisterWebhooks(client, service)
+		webhook.RegisterWebhooks(client, service, tokenIssue, jenkins)
 		container.Add(service)
 	}
 	return services
diff --git a/pkg/kapis/devops/v1alpha3/webhook/register.go b/pkg/kapis/devops/v1alpha3/webhook/register.go
index ba351b1..a0d4149 100644
--- a/pkg/kapis/devops/v1alpha3/webhook/register.go
+++ b/pkg/kapis/devops/v1alpha3/webhook/register.go
@@ -17,6 +17,8 @@ limitations under the License.
 package webhook
 
 import (
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"net/http"
 
 	"github.com/emicklei/go-restful"
@@ -25,10 +27,14 @@ import (
 )
 
 // RegisterWebhooks registers all webhooks into web service.
-func RegisterWebhooks(genericClient client.Client, ws *restful.WebService) {
+func RegisterWebhooks(genericClient client.Client, ws *restful.WebService, issue token.Issuer, jenkins core.JenkinsCore) {
 	webhookHandler := NewHandler(genericClient)
 	ws.Route(ws.POST("/webhooks/jenkins").
 		To(webhookHandler.ReceiveEventsFromJenkins).
 		Doc("Webhook for receiving events from Jenkins").
 		Returns(http.StatusOK, api.StatusOK, nil))
+
+	scmHandler := NewSCMHandler(genericClient, issue, jenkins)
+	ws.Route(ws.POST("/webhooks/scm").
+		To(scmHandler.scmWebhook))
 }
diff --git a/pkg/kapis/devops/v1alpha3/webhook/scm.go b/pkg/kapis/devops/v1alpha3/webhook/scm.go
new file mode 100644
index 0000000..20a357e
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha3/webhook/scm.go
@@ -0,0 +1,191 @@
+/*
+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 webhook
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"github.com/emicklei/go-restful"
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/go-scm/scm/driver/bitbucket"
+	"github.com/jenkins-x/go-scm/scm/driver/github"
+	"github.com/jenkins-x/go-scm/scm/driver/gitlab"
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"github.com/jenkins-zh/jenkins-client/pkg/job"
+	"k8s.io/apiserver/pkg/authentication/user"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"kubesphere.io/devops/pkg/client/devops"
+	"kubesphere.io/devops/pkg/jwt/token"
+	"kubesphere.io/devops/pkg/kapis/devops/v1alpha3/pipelinerun"
+	"net/http"
+	"regexp"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"strings"
+	"time"
+)
+
+// tokenExpireIn indicates that the temporary token issued by controller will be expired in some time.
+const tokenExpireIn time.Duration = 5 * time.Minute
+const scmAnnotationKey = "scm.devops.kubesphere.io"
+const scmRefAnnotationKey = "scm.devops.kubesphere.io/ref"
+const triggerAnnotationKey = "devops.kubesphere.io/trigger"
+
+// SCMHandler handles requests from webhooks.
+type SCMHandler struct {
+	client.Client
+	issue   token.Issuer
+	jenkins core.JenkinsCore
+}
+
+// NewSCMHandler creates a new handler for handling webhooks.
+func NewSCMHandler(genericClient client.Client, issue token.Issuer, jenkins core.JenkinsCore) *SCMHandler {
+	return &SCMHandler{
+		Client:  genericClient,
+		issue:   issue,
+		jenkins: jenkins,
+	}
+}
+
+func getSCMClient(request *http.Request) *scm.Client {
+	if request.Header.Get("X-Gitlab-Event") != "" {
+		return gitlab.NewDefault()
+	}
+
+	if request.Header.Get("X-GitHub-Event") != "" {
+		return github.NewDefault()
+	}
+
+	if strings.HasPrefix(request.Header.Get("User-Agent"), "Bitbucket-Webhooks") {
+		return bitbucket.NewDefault()
+	}
+	return nil
+}
+
+func (h *SCMHandler) scmWebhook(request *restful.Request, response *restful.Response) {
+	scmClient := getSCMClient(request.Request)
+	if scmClient == nil {
+		_, _ = response.Write([]byte("unknown SCM type"))
+		return
+	}
+
+	webhook, err := scmClient.Webhooks.Parse(request.Request, func(webhook scm.Webhook) (string, error) {
+		return "", nil
+	})
+	if err != nil {
+		_, _ = response.Write([]byte(err.Error()))
+		return
+	}
+
+	ctx := context.TODO()
+	found := false
+	if webhook.Kind() == scm.WebhookKindPush {
+		repo := webhook.Repository()
+		link := repo.Link
+
+		pushHook := webhook.(*scm.PushHook)
+
+		pipelineList := &v1alpha3.PipelineList{}
+		if err = h.List(ctx, pipelineList); err == nil {
+			for i := range pipelineList.Items {
+				pipeline := pipelineList.Items[i]
+				if !branchMatch(pipeline, pushHook.Ref) {
+					continue
+				}
+				found = true
+
+				if pipeline.IsMultiBranch() {
+					gitURL := pipeline.Spec.MultiBranchPipeline.GetGitURL()
+					if gitURL != "" && gitRepoMatch(gitURL, link) {
+						err = scanJenkinsMultiBranchPipeline(pipeline, h.jenkins, h.issue)
+					}
+				} else {
+					gitURL := pipeline.GetAnnotations()[scmAnnotationKey]
+					if gitRepoMatch(gitURL, link) {
+						err = h.createPipelineRun(pipeline, pushHook)
+					}
+				}
+			}
+		}
+	}
+
+	if !found {
+		_ = response.WriteErrorString(http.StatusOK, "no pipeline matched")
+		return
+	} else if err != nil {
+		_ = response.WriteError(http.StatusBadRequest, err)
+	} else {
+		_, _ = response.Write([]byte("ok"))
+	}
+}
+
+func (h *SCMHandler) createPipelineRun(pipeline v1alpha3.Pipeline, hook *scm.PushHook) (err error) {
+	branch := strings.TrimPrefix(hook.Ref, "refs/heads/")
+
+	var scmObj *v1alpha3.SCM
+	if scmObj, err = pipelinerun.CreateScm(&pipeline.Spec, branch); err == nil {
+		run := pipelinerun.CreatePipelineRun(&pipeline, &devops.RunPayload{}, scmObj)
+		run.Annotations[triggerAnnotationKey] = "webhook"
+		err = h.Create(context.Background(), run)
+	}
+	return
+}
+
+func scanJenkinsMultiBranchPipeline(pipeline v1alpha3.Pipeline, jenkins core.JenkinsCore, issue token.Issuer) (err error) {
+	var accessToken string
+	accessToken, err = issue.IssueTo(&user.DefaultInfo{Name: "admin"}, token.AccessToken, tokenExpireIn)
+	if err != nil {
+		err = fmt.Errorf("failed to issue access token for creator webhook, error was %v", err)
+		return
+	}
+
+	// using a dynamic Jenkins token instead of the static one
+	jenkins.Token = accessToken
+	jclient := job.Client{
+		JenkinsCore: jenkins,
+	}
+
+	err = jclient.Build(fmt.Sprintf("%s %s", pipeline.Namespace, pipeline.Name))
+	return
+}
+
+// branchMatch matches the branch rules from annotation.
+// It supports regexp pattern, or returns true if no annotation found
+func branchMatch(pipeline v1alpha3.Pipeline, branch string) (ok bool) {
+	branchRules := pipeline.Annotations[scmRefAnnotationKey]
+	if branchRules == "" {
+		ok = true
+		return
+	}
+	var branchSlice []string
+	if err := json.Unmarshal([]byte(branchRules), &branchSlice); err == nil {
+		for i := range branchSlice {
+			rule := branchSlice[i]
+
+			if ok, _ = regexp.Match(rule, []byte(branch)); ok {
+				return
+			}
+		}
+	}
+	return
+}
+
+// gitRepoMatch if the source matches target
+func gitRepoMatch(source, target string) (ok bool) {
+	ok = source == target
+	return
+}
diff --git a/pkg/models/pipeline/pipeline.go b/pkg/models/pipeline/pipeline.go
index 7f1177c..76f2b6e 100644
--- a/pkg/models/pipeline/pipeline.go
+++ b/pkg/models/pipeline/pipeline.go
@@ -16,7 +16,10 @@ limitations under the License.
 
 package pipeline
 
-import "github.com/jenkins-zh/jenkins-client/pkg/job"
+import (
+	"encoding/json"
+	"github.com/jenkins-zh/jenkins-client/pkg/job"
+)
 
 // Metadata holds some of pipeline fields that are only things we needed instead of whole job.Pipeline.
 type Metadata struct {
@@ -56,7 +59,14 @@ type Branch struct {
 // BranchSlice is alias of branch slice.
 type BranchSlice []Branch
 
-// SearchByName searchs branch by its name.
+// GetBranchSlice parse from a JSON text
+func GetBranchSlice(jsonText string) (branches BranchSlice, err error) {
+	branches = []Branch{}
+	err = json.Unmarshal([]byte(jsonText), &branches)
+	return
+}
+
+// SearchByName searches branch by its name.
 func (branches BranchSlice) SearchByName(name string) (bool, *Branch) {
 	i := 0
 	for ; i < len(branches); i++ {


Test patch:
diff --git a/pkg/api/devops/v1alpha3/last_changes_test.go b/pkg/api/devops/v1alpha3/last_changes_test.go
new file mode 100644
index 0000000..2254794
--- /dev/null
+++ b/pkg/api/devops/v1alpha3/last_changes_test.go
@@ -0,0 +1,136 @@
+/*
+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 v1alpha3
+
+import (
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestGetLastChanges(t *testing.T) {
+	type args struct {
+		jsonText string
+	}
+	tests := []struct {
+		name           string
+		args           args
+		wantLastChange LastChanges
+		wantErr        assert.ErrorAssertionFunc
+	}{{
+		name: "normal JSON data with map format",
+		args: args{jsonText: `{"master":"1234"}`},
+		wantLastChange: map[string]string{
+			"master": "1234",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return false
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotLastChange, err := GetLastChanges(tt.args.jsonText)
+			if !tt.wantErr(t, err, fmt.Sprintf("GetLastChanges(%v)", tt.args.jsonText)) {
+				return
+			}
+			assert.Equalf(t, tt.wantLastChange, gotLastChange, "GetLastChanges(%v)", tt.args.jsonText)
+		})
+	}
+}
+
+func TestLastChanges_Update(t *testing.T) {
+	type args struct {
+		ref  string
+		hash string
+	}
+	tests := []struct {
+		name string
+		l    LastChanges
+		args args
+		want LastChanges
+	}{{
+		name: "update the not existing value",
+		l:    map[string]string{},
+		args: args{
+			ref:  "master",
+			hash: "2345",
+		},
+		want: map[string]string{
+			"master": "2345",
+		},
+	}, {
+		name: "update the existing value",
+		l: map[string]string{
+			"master": "1234",
+		},
+		args: args{
+			ref:  "master",
+			hash: "2345",
+		},
+		want: map[string]string{
+			"master": "2345",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, tt.l.Update(tt.args.ref, tt.args.hash), "Update(%v, %v)", tt.args.ref, tt.args.hash)
+		})
+	}
+}
+
+func TestLastChanges_LastHash(t *testing.T) {
+	type args struct {
+		ref string
+	}
+	tests := []struct {
+		name     string
+		l        LastChanges
+		args     args
+		wantHash string
+	}{{
+		name: "normal case",
+		l: map[string]string{
+			"master": "1234",
+		},
+		args:     args{ref: "master"},
+		wantHash: "1234",
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.wantHash, tt.l.LastHash(tt.args.ref), "LastHash(%v)", tt.args.ref)
+		})
+	}
+}
+
+func TestLastChanges_String(t *testing.T) {
+	tests := []struct {
+		name string
+		l    LastChanges
+		want string
+	}{{
+		name: "normal case",
+		l: map[string]string{
+			"master": "1234",
+		},
+		want: `{"master":"1234"}`,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, tt.l.String(), "String()")
+		})
+	}
+}
diff --git a/pkg/api/devops/v1alpha3/pipeline_types_test.go b/pkg/api/devops/v1alpha3/pipeline_types_test.go
index 29e1c87..5e9518b 100644
--- a/pkg/api/devops/v1alpha3/pipeline_types_test.go
+++ b/pkg/api/devops/v1alpha3/pipeline_types_test.go
@@ -17,6 +17,7 @@ limitations under the License.
 package v1alpha3
 
 import (
+	"github.com/stretchr/testify/assert"
 	"testing"
 )
 
@@ -61,3 +62,65 @@ func TestPipeline_IsMultiBranch(t *testing.T) {
 		})
 	}
 }
+
+func TestMultiBranchPipeline_GetGitURL(t *testing.T) {
+	type fields struct {
+		SourceType            string
+		GitSource             *GitSource
+		GitHubSource          *GithubSource
+		GitlabSource          *GitlabSource
+		BitbucketServerSource *BitbucketServerSource
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		want   string
+	}{{
+		name: "github",
+		fields: fields{
+			SourceType:   SourceTypeGithub,
+			GitHubSource: &GithubSource{Owner: "linuxsuren", Repo: "tools"},
+		},
+		want: "https://github.com/linuxsuren/tools",
+	}, {
+		name: "gitlab",
+		fields: fields{
+			SourceType:   SourceTypeGitlab,
+			GitlabSource: &GitlabSource{Owner: "linuxsuren", Repo: "tools"},
+		},
+		want: "https://gitlab.com/linuxsuren/tools",
+	}, {
+		name: "git",
+		fields: fields{
+			SourceType: SourceTypeGit,
+			GitSource:  &GitSource{Url: "https://fake.com"},
+		},
+		want: "https://fake.com",
+	}, {
+		name: "bitbucket",
+		fields: fields{
+			SourceType:            SourceTypeBitbucket,
+			BitbucketServerSource: &BitbucketServerSource{Owner: "linuxsuren", Repo: "tools"},
+		},
+		want: "https://bitbucket.org/linuxsuren/tools",
+	}, {
+		name: "fake",
+		fields: fields{
+			SourceType: "fake",
+			GitSource:  &GitSource{Url: "https://fake.com"},
+		},
+		want: "",
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			b := &MultiBranchPipeline{
+				SourceType:            tt.fields.SourceType,
+				GitSource:             tt.fields.GitSource,
+				GitHubSource:          tt.fields.GitHubSource,
+				GitlabSource:          tt.fields.GitlabSource,
+				BitbucketServerSource: tt.fields.BitbucketServerSource,
+			}
+			assert.Equalf(t, tt.want, b.GetGitURL(), "GetGitURL()")
+		})
+	}
+}
diff --git a/pkg/jwt/token/fake_issuer_test.go b/pkg/jwt/token/fake_issuer_test.go
new file mode 100644
index 0000000..f041676
--- /dev/null
+++ b/pkg/jwt/token/fake_issuer_test.go
@@ -0,0 +1,40 @@
+/*
+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 token
+
+import (
+	"github.com/stretchr/testify/assert"
+	"k8s.io/apiserver/pkg/authentication/user"
+	"testing"
+)
+
+func TestFakeIssuer_IssueTo(t *testing.T) {
+	f := &FakeIssuer{
+		Token:        "Token",
+		IssueToError: nil,
+		VerifyError:  nil,
+	}
+	got, err := f.IssueTo(&user.DefaultInfo{}, "type", 0)
+	assert.Equal(t, "Token", got)
+	assert.Nil(t, err)
+
+	_, _, err = f.Verify("token")
+	assert.Nil(t, err)
+
+	_, _, err = f.VerifyWithoutClaimsValidation("token")
+	assert.Nil(t, err)
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go
index c80c47a..47392f1 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go
@@ -18,6 +18,7 @@ package pipelinerun
 
 import (
 	"fmt"
+	"github.com/stretchr/testify/assert"
 	"reflect"
 	"testing"
 
@@ -215,3 +216,14 @@ func Test_convertParameters(t *testing.T) {
 		})
 	}
 }
+
+func TestCreatePipelineRun(t *testing.T) {
+	pipeline := &v1alpha3.Pipeline{}
+	pipeline.SetName("name")
+	pipeline.Namespace = "namespace"
+	pipelineRun := CreatePipelineRun(pipeline, nil, nil)
+
+	assert.Equal(t, pipelineRun.GenerateName, pipeline.Name+"-")
+	assert.Equal(t, pipelineRun.Namespace, pipeline.Namespace)
+	assert.NotNil(t, pipelineRun.Annotations)
+}
diff --git a/pkg/kapis/devops/v1alpha3/register_test.go b/pkg/kapis/devops/v1alpha3/register_test.go
index cd2b756..1439302 100644
--- a/pkg/kapis/devops/v1alpha3/register_test.go
+++ b/pkg/kapis/devops/v1alpha3/register_test.go
@@ -18,6 +18,8 @@ package v1alpha3
 
 import (
 	"context"
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -44,23 +46,21 @@ func TestAPIsExist(t *testing.T) {
 	assert.Nil(t, err)
 
 	container := restful.NewContainer()
-	AddToContainer(container, fakedevops.NewFakeDevops(nil),
-		k8s.NewFakeClientSets(k8sfake.NewSimpleClientset(&v1.Secret{
-			ObjectMeta: metav1.ObjectMeta{
-				Name: "fake", Namespace: "fake",
-			},
-		}), nil, nil, "", nil,
-			fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
-				ObjectMeta: metav1.ObjectMeta{Name: "fake"},
-				Status:     v1alpha3.DevOpsProjectStatus{AdminNamespace: "fake"},
-			}, &v1alpha3.Pipeline{
-				ObjectMeta: metav1.ObjectMeta{Namespace: "fake", Name: "fake"},
-			})),
-		fake.NewFakeClientWithScheme(schema, &v1.Secret{
-			ObjectMeta: metav1.ObjectMeta{
-				Name: "fake", Namespace: "fake",
-			},
-		}))
+	AddToContainer(container, fakedevops.NewFakeDevops(nil), k8s.NewFakeClientSets(k8sfake.NewSimpleClientset(&v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "fake", Namespace: "fake",
+		},
+	}), nil, nil, "", nil,
+		fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
+			ObjectMeta: metav1.ObjectMeta{Name: "fake"},
+			Status:     v1alpha3.DevOpsProjectStatus{AdminNamespace: "fake"},
+		}, &v1alpha3.Pipeline{
+			ObjectMeta: metav1.ObjectMeta{Namespace: "fake", Name: "fake"},
+		})), fake.NewFakeClientWithScheme(schema, &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "fake", Namespace: "fake",
+		},
+	}), &token.FakeIssuer{}, core.JenkinsCore{})
 
 	type args struct {
 		method string
@@ -178,18 +178,16 @@ func TestGetDevOpsProject(t *testing.T) {
 	assert.Nil(t, err)
 	container := restful.NewContainer()
 
-	AddToContainer(container, fakedevops.NewFakeDevops(nil),
-		k8s.NewFakeClientSets(k8sfake.NewSimpleClientset(), nil, nil, "", nil,
-			fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
-				ObjectMeta: metav1.ObjectMeta{
-					GenerateName: "fake",
-					Name:         "generated-fake",
-					Labels: map[string]string{
-						constants.WorkspaceLabelKey: "ws",
-					},
+	AddToContainer(container, fakedevops.NewFakeDevops(nil), k8s.NewFakeClientSets(k8sfake.NewSimpleClientset(), nil, nil, "", nil,
+		fakeclientset.NewSimpleClientset(&v1alpha3.DevOpsProject{
+			ObjectMeta: metav1.ObjectMeta{
+				GenerateName: "fake",
+				Name:         "generated-fake",
+				Labels: map[string]string{
+					constants.WorkspaceLabelKey: "ws",
 				},
-			})),
-		fake.NewFakeClientWithScheme(schema))
+			},
+		})), fake.NewFakeClientWithScheme(schema), &token.FakeIssuer{}, core.JenkinsCore{})
 
 	type args struct {
 		method string
diff --git a/pkg/kapis/devops/v1alpha3/webhook/register_test.go b/pkg/kapis/devops/v1alpha3/webhook/register_test.go
index 1a62e06..648624c 100644
--- a/pkg/kapis/devops/v1alpha3/webhook/register_test.go
+++ b/pkg/kapis/devops/v1alpha3/webhook/register_test.go
@@ -20,10 +20,12 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	"io"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	"k8s.io/client-go/kubernetes/scheme"
 	"k8s.io/client-go/util/retry"
+	"kubesphere.io/devops/pkg/jwt/token"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -41,7 +43,6 @@ import (
 )
 
 func TestJenkinsWebhook(t *testing.T) {
-
 	type args struct {
 		method     string
 		uri        string
@@ -159,7 +160,7 @@ func TestJenkinsWebhook(t *testing.T) {
 
 			container := restful.NewContainer()
 			wsWithGroup := apiserverruntime.NewWebService(v1alpha3.GroupVersion)
-			RegisterWebhooks(fakeClient, wsWithGroup)
+			RegisterWebhooks(fakeClient, wsWithGroup, &token.FakeIssuer{}, core.JenkinsCore{})
 			container.Add(wsWithGroup)
 
 			var bodyReader io.Reader
@@ -180,6 +181,209 @@ func TestJenkinsWebhook(t *testing.T) {
 	}
 }
 
+func TestSCMWebhook(t *testing.T) {
+	defaultPipeline := &v1alpha3.Pipeline{}
+	defaultPipeline.SetName("fake")
+	defaultPipeline.SetNamespace("default")
+	defaultPipeline.SetAnnotations(map[string]string{
+		scmRefAnnotationKey: `["master"]`,
+		scmAnnotationKey:    "https://gitlab.com/linuxsuren/test",
+	})
+
+	type args struct {
+		method     string
+		uri        string
+		bodyJSON   string
+		header     map[string]string
+		initObject []runtime.Object
+	}
+	tests := []struct {
+		name      string
+		args      args
+		assertion func(t *testing.T, c client.Client, body string)
+	}{{
+		name: "unknown SCM webhook",
+		args: args{
+			method:     http.MethodPost,
+			uri:        "/webhooks/scm",
+			initObject: []runtime.Object{},
+		},
+		assertion: func(t *testing.T, c client.Client, body string) {
+			assert.Equal(t, "unknown SCM type", body)
+		},
+	}, {
+		name: "gitlab webhook with no pipeline matched",
+		args: args{
+			method:     http.MethodPost,
+			uri:        "/webhooks/scm",
+			initObject: []runtime.Object{},
+			bodyJSON:   gitlabWebhookBody,
+			header: map[string]string{
+				"X-Gitlab-Event": "Push Hook",
+			},
+		},
+		assertion: func(t *testing.T, c client.Client, body string) {
+			assert.Equal(t, "no pipeline matched", body)
+		},
+	}, {
+		name: "gitlab webhook",
+		args: args{
+			method:     http.MethodPost,
+			uri:        "/webhooks/scm",
+			initObject: []runtime.Object{defaultPipeline.DeepCopy()},
+			bodyJSON:   gitlabWebhookBody,
+			header: map[string]string{
+				"X-Gitlab-Event": "Push Hook",
+			},
+		},
+		assertion: func(t *testing.T, c client.Client, body string) {
+			assert.Equal(t, "ok", body)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
+			fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObject...)
+
+			container := restful.NewContainer()
+			wsWithGroup := apiserverruntime.NewWebService(v1alpha3.GroupVersion)
+			RegisterWebhooks(fakeClient, wsWithGroup, &token.FakeIssuer{}, core.JenkinsCore{})
+			container.Add(wsWithGroup)
+
+			var bodyReader io.Reader
+			if tt.args.bodyJSON != "" {
+				bodyReader = strings.NewReader(tt.args.bodyJSON)
+			}
+
+			httpRequest, _ := http.NewRequest(tt.args.method,
+				"http://fake.com/kapis/devops.kubesphere.io/v1alpha3"+tt.args.uri, bodyReader)
+			httpRequest.Header.Set("Content-Type", "application/json")
+			for k, v := range tt.args.header {
+				httpRequest.Header.Set(k, v)
+			}
+			httpWriter := httptest.NewRecorder()
+			container.Dispatch(httpWriter, httpRequest)
+			assert.Equal(t, 200, httpWriter.Code)
+			if tt.assertion != nil {
+				body := httpWriter.Body
+				var bodyResponse string
+				if body != nil {
+					bodyResponse = body.String()
+				}
+				tt.assertion(t, fakeClient, bodyResponse)
+			}
+		})
+	}
+}
+
+const gitlabWebhookBody = `{
+  "object_kind": "push",
+  "event_name": "push",
+  "before": "8f4b347e7d6b7647b51647dcd07ddafd4bded19f",
+  "after": "bd4f171cec5c6f9b8b184107ce318bf9a54dce26",
+  "ref": "refs/heads/master",
+  "checkout_sha": "bd4f171cec5c6f9b8b184107ce318bf9a54dce26",
+  "message": null,
+  "user_id": 1269616,
+  "user_name": "Rick",
+  "user_username": "linuxsuren",
+  "user_email": "",
+  "user_avatar": "https://secure.gravatar.com/avatar/1f14a87866e88e19426d164e4d3e078a?s=80&d=identicon",
+  "project_id": 26266137,
+  "project": {
+    "id": 26266137,
+    "name": "test",
+    "description": "",
+    "web_url": "https://gitlab.com/linuxsuren/test",
+    "avatar_url": null,
+    "git_ssh_url": "git@gitlab.com:linuxsuren/test.git",
+    "git_http_url": "https://gitlab.com/linuxsuren/test.git",
+    "namespace": "Rick",
+    "visibility_level": 20,
+    "path_with_namespace": "linuxsuren/test",
+    "default_branch": "master",
+    "ci_config_path": "",
+    "homepage": "https://gitlab.com/linuxsuren/test",
+    "url": "git@gitlab.com:linuxsuren/test.git",
+    "ssh_url": "git@gitlab.com:linuxsuren/test.git",
+    "http_url": "https://gitlab.com/linuxsuren/test.git"
+  },
+  "commits": [
+    {
+      "id": "bd4f171cec5c6f9b8b184107ce318bf9a54dce26",
+      "message": "Merge branch '10' into 'master'\n\nAdd new file\n\nSee merge request linuxsuren/test!1",
+      "title": "Merge branch '10' into 'master'",
+      "timestamp": "2021-04-29T10:41:08+00:00",
+      "url": "https://gitlab.com/linuxsuren/test/-/commit/bd4f171cec5c6f9b8b184107ce318bf9a54dce26",
+      "author": {
+        "name": "Rick",
+        "email": "linuxsuren@gmail.com"
+      },
+      "added": [
+        "Jenkinsfile"
+      ],
+      "modified": [
+
+      ],
+      "removed": [
+
+      ]
+    },
+    {
+      "id": "1c433d04a2c306c414d8542908fc30ab8b855c65",
+      "message": "Add new file",
+      "title": "Add new file",
+      "timestamp": "2021-04-29T10:40:41+00:00",
+      "url": "https://gitlab.com/linuxsuren/test/-/commit/1c433d04a2c306c414d8542908fc30ab8b855c65",
+      "author": {
+        "name": "Rick",
+        "email": "linuxsuren@gmail.com"
+      },
+      "added": [
+        "Jenkinsfile"
+      ],
+      "modified": [
+
+      ],
+      "removed": [
+
+      ]
+    },
+    {
+      "id": "8f4b347e7d6b7647b51647dcd07ddafd4bded19f",
+      "message": "Initial commit",
+      "title": "Initial commit",
+      "timestamp": "2021-04-29T10:37:37+00:00",
+      "url": "https://gitlab.com/linuxsuren/test/-/commit/8f4b347e7d6b7647b51647dcd07ddafd4bded19f",
+      "author": {
+        "name": "Rick",
+        "email": "linuxsuren@gmail.com"
+      },
+      "added": [
+        "README.md"
+      ],
+      "modified": [
+
+      ],
+      "removed": [
+
+      ]
+    }
+  ],
+  "total_commits_count": 3,
+  "push_options": {
+  },
+  "repository": {
+    "name": "test",
+    "url": "git@gitlab.com:linuxsuren/test.git",
+    "description": "",
+    "homepage": "https://gitlab.com/linuxsuren/test",
+    "git_http_url": "https://gitlab.com/linuxsuren/test.git",
+    "git_ssh_url": "git@gitlab.com:linuxsuren/test.git",
+    "visibility_level": 20
+  }
+}`
+
 func Test(t *testing.T) {
 	err := retry.OnError(retry.DefaultRetry, func(err error) bool {
 		return true
diff --git a/pkg/kapis/devops/v1alpha3/webhook/scm_test.go b/pkg/kapis/devops/v1alpha3/webhook/scm_test.go
new file mode 100644
index 0000000..5dc845e
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha3/webhook/scm_test.go
@@ -0,0 +1,136 @@
+/*
+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 webhook
+
+import (
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/go-scm/scm/driver/bitbucket"
+	"github.com/jenkins-x/go-scm/scm/driver/github"
+	"github.com/jenkins-x/go-scm/scm/driver/gitlab"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"net/http"
+	"testing"
+)
+
+func Test_getSCMClient(t *testing.T) {
+	type args struct {
+		request func() *http.Request
+	}
+	tests := []struct {
+		name string
+		args args
+		want *scm.Client
+	}{{
+		name: "github",
+		args: args{
+			request: func() *http.Request {
+				defaultRequest := &http.Request{}
+				defaultRequest.Header = map[string][]string{}
+				defaultRequest.Header.Add("X-GitHub-Event", "event")
+				return defaultRequest
+			},
+		},
+		want: github.NewDefault(),
+	}, {
+		name: "gtilab",
+		args: args{
+			request: func() *http.Request {
+				defaultRequest := &http.Request{}
+				defaultRequest.Header = map[string][]string{}
+				defaultRequest.Header.Add("X-Gitlab-Event", "event")
+				return defaultRequest
+			},
+		},
+		want: gitlab.NewDefault(),
+	}, {
+		name: "bitbucket",
+		args: args{
+			request: func() *http.Request {
+				defaultRequest := &http.Request{}
+				defaultRequest.Header = map[string][]string{}
+				defaultRequest.Header.Add("User-Agent", "Bitbucket-Webhooks")
+				return defaultRequest
+			},
+		},
+		want: bitbucket.NewDefault(),
+	}, {
+		name: "unknown SCM provider",
+		args: args{
+			request: func() *http.Request {
+				return &http.Request{}
+			},
+		},
+		want: nil,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, getSCMClient(tt.args.request()), "getSCMClient(%v)", tt.args.request())
+		})
+	}
+}
+
+func Test_branchMatch(t *testing.T) {
+	type args struct {
+		pipeline v1alpha3.Pipeline
+		branch   string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		wantOk bool
+	}{{
+		name: "no any annotations",
+		args: args{
+			pipeline: v1alpha3.Pipeline{},
+			branch:   "master",
+		},
+		wantOk: true,
+	}, {
+		name: "branch name equal literally",
+		args: args{
+			pipeline: v1alpha3.Pipeline{
+				ObjectMeta: v1.ObjectMeta{
+					Annotations: map[string]string{
+						scmRefAnnotationKey: `["master", "good"]`,
+					},
+				},
+			},
+			branch: "master",
+		},
+		wantOk: true,
+	}, {
+		name: "branch name equal in regex",
+		args: args{
+			pipeline: v1alpha3.Pipeline{
+				ObjectMeta: v1.ObjectMeta{
+					Annotations: map[string]string{
+						scmRefAnnotationKey: `["feat-.*", "good"]`,
+					},
+				},
+			},
+			branch: "feat-login",
+		},
+		wantOk: true,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.wantOk, branchMatch(tt.args.pipeline, tt.args.branch), "branchMatch(%v, %v)", tt.args.pipeline, tt.args.branch)
+		})
+	}
+}
diff --git a/pkg/models/pipeline/pipeline_test.go b/pkg/models/pipeline/pipeline_test.go
index 4439505..5f7958f 100644
--- a/pkg/models/pipeline/pipeline_test.go
+++ b/pkg/models/pipeline/pipeline_test.go
@@ -84,3 +84,36 @@ func TestBranchSlice_SearchByName(t *testing.T) {
 		})
 	}
 }
+
+func TestGetBranchSlice(t *testing.T) {
+	type args struct {
+		jsonText string
+	}
+	tests := []struct {
+		name         string
+		args         args
+		wantBranches BranchSlice
+		wantErr      bool
+	}{{
+		name: "normal",
+		args: args{
+			jsonText: `[{"name":"master"}]`,
+		},
+		wantErr: false,
+		wantBranches: []Branch{{
+			Name: "master",
+		}},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotBranches, err := GetBranchSlice(tt.args.jsonText)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("GetBranchSlice() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(gotBranches, tt.wantBranches) {
+				t.Errorf("GetBranchSlice() gotBranches = %v, want %v", gotBranches, tt.wantBranches)
+			}
+		})
+	}
+}