Repository: kubesphere/ks-devops
Pull request #797 (2022-08-25 10:01:56)

Support to add a Pipeline status link to git provider (github, gitlab, bitbucket)
<!-- 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 feature

### What this PR does / why we need it:

I have tested it on GitHub, Gitlab, and Bitbucket.

| Git Provider | Auth | Description |
|---|---|---|
| GitHub | Personal Token | Pass |
| Gitlab | Personal Token | Pass |
| Bitbucket | Username and App password | Pass |

Users need to set the external address of UI to the controller. Such as `--external-address=http://suren-ubuntu.local:30880/`

<details>
  <summary><b>Screenshots of the test process</b></summary>

<img src="https://user-images.githubusercontent.com/1450685/186637184-68c6010c-04a7-4b4a-a00a-3ceccd8f38c7.jpg"/>

<img src="https://user-images.githubusercontent.com/1450685/186637202-277af5e3-a31c-4fae-9372-31a1f5bf0f1f.jpg"/>

<img src="https://user-images.githubusercontent.com/1450685/186637221-29b1b46f-daee-4a27-83ef-2e0c96327825.jpg"/>
</details>

### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
Please leave it or change # to be None if there is no corresponding issue that exists
-->
Fixes #

### Special notes for reviewers:
<!--
You can use the following command to let the DevOps SIG members help you to review your PR.
/cc @kubesphere/sig-devops 
And please avoid cc any individual.
-->
Please check the following list before waiting reviewers:

- [ ] Already committed the CRD files to [the Helm Chart](https://github.com/kubesphere-sigs/ks-devops-helm-chart/) if you created some new CRDs
- [ ] Already [added the permission](https://github.com/kubesphere/ks-installer/blob/9e063b085a0e43fdb3d0d9e3e7f4149146f14b9c/roles/ks-core/prepare/files/ks-init/role-templates.yaml) for the new API
- [ ] Already added the RBAC markers for the new controllers

### Does this PR introduce a user-facing change??
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended-release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

Please keep the note be same as your PR title if you believe it should be in the release notes.
-->
```release-note
Support to add a Pipeline status link to git provider (github, gitlab, bitbucket)
```


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"]} -->
codecov[bot]: # [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/797?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#797](https://codecov.io/gh/kubesphere/ks-devops/pull/797?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (72f5fce) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/f45b22d189a1593bf1ef157f54970a38a68495db?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (f45b22d) will **increase** coverage by `2.16%`.
> The diff coverage is `86.84%`.

```diff
@@            Coverage Diff             @@
##           master     #797      +/-   ##
==========================================
+ Coverage   52.75%   54.91%   +2.16%     
==========================================
  Files         180      182       +2     
  Lines       10363    10820     +457     
==========================================
+ Hits         5467     5942     +475     
+ Misses       4603     4555      -48     
- Partials      293      323      +30     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `54.91% <86.84%> (+2.16%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/kubesphere/ks-devops/pull/797?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [pkg/api/devops/v1alpha3/pipelinerun\_types.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaS9kZXZvcHMvdjFhbHBoYTMvcGlwZWxpbmVydW5fdHlwZXMuZ28=) | `38.09% <ø> (ø)` | |
| [...rs/gitrepository/pull\_request\_status\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvZ2l0cmVwb3NpdG9yeS9wdWxsX3JlcXVlc3Rfc3RhdHVzX2NvbnRyb2xsZXIuZ28=) | `86.11% <86.11%> (ø)` | |
| [cmd/controller/app/options/feature.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y21kL2NvbnRyb2xsZXIvYXBwL29wdGlvbnMvZmVhdHVyZS5nbw==) | `100.00% <100.00%> (+45.45%)` | :arrow_up: |
| [cmd/controller/app/options/options.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y21kL2NvbnRyb2xsZXIvYXBwL29wdGlvbnMvb3B0aW9ucy5nbw==) | `100.00% <100.00%> (+100.00%)` | :arrow_up: |
| [pkg/utils/net/net.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3V0aWxzL25ldC9uZXQuZ28=) | `100.00% <100.00%> (ø)` | |
| [pkg/apis/apis.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaXMvYXBpcy5nbw==) | `100.00% <0.00%> (ø)` | |
| [pkg/api/gitops/v1alpha1/application.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaS9naXRvcHMvdjFhbHBoYTEvYXBwbGljYXRpb24uZ28=) | `100.00% <0.00%> (ø)` | |
| [controllers/fluxcd/application-controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvZmx1eGNkL2FwcGxpY2F0aW9uLWNvbnRyb2xsZXIuZ28=) | `84.10% <0.00%> (ø)` | |
| [...ns/devopscredential/devopscredential\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9kZXZvcHNjcmVkZW50aWFsL2Rldm9wc2NyZWRlbnRpYWxfY29udHJvbGxlci5nbw==) | `36.73% <0.00%> (+3.40%)` | :arrow_up: |
| [...ontrollers/jenkins/pipeline/pipeline\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZS9waXBlbGluZV9jb250cm9sbGVyLmdv) | `43.18% <0.00%> (+3.78%)` | :arrow_up: |
| ... and [3 more](https://codecov.io/gh/kubesphere/ks-devops/pull/797/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)


Patch:
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index 12a11fe..6e0f0a3 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -139,6 +139,13 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i
 
 	return map[string]func(mgr manager.Manager) error{
 		gitRepoReconcilers.GetName(): func(mgr manager.Manager) error {
+			err := (&gitrepository.PullRequestStatusReconciler{
+				Client:          mgr.GetClient(),
+				ExternalAddress: s.FeatureOptions.ExternalAddress,
+			}).SetupWithManager(mgr)
+			if err != nil {
+				return err
+			}
 			return gitRepoReconcilers.SetupWithManager(mgr)
 		},
 		"addon": func(mgr manager.Manager) error {
diff --git a/cmd/controller/app/options/feature.go b/cmd/controller/app/options/feature.go
index cebb039..06319e5 100644
--- a/cmd/controller/app/options/feature.go
+++ b/cmd/controller/app/options/feature.go
@@ -28,6 +28,7 @@ import (
 type FeatureOptions struct {
 	Controllers     map[string]bool
 	SystemNamespace string
+	ExternalAddress string
 }
 
 // GetControllers returns the controllers map
@@ -77,6 +78,7 @@ func (o *FeatureOptions) AddFlags(fs *pflag.FlagSet, c *FeatureOptions) {
 		"Options are:\n"+strings.Join(c.knownControllers(), "\n"))
 	fs.StringVarP(&o.SystemNamespace, "system-namespace", "", "kubesphere-devops-system",
 		"The system namespace that contains ConfigMap, Secrets e.g.")
+	fs.StringVarP(&o.ExternalAddress, "external-address", "", "", "The external address for the UI")
 }
 
 func (o *FeatureOptions) knownControllers() []string {
diff --git a/cmd/controller/app/options/options.go b/cmd/controller/app/options/options.go
index b503ebd..70ceb6f 100644
--- a/cmd/controller/app/options/options.go
+++ b/cmd/controller/app/options/options.go
@@ -18,10 +18,11 @@ package options
 
 import (
 	"flag"
-	"kubesphere.io/devops/pkg/config"
 	"strings"
 	"time"
 
+	"kubesphere.io/devops/pkg/config"
+
 	"kubesphere.io/devops/pkg/client/devops/jenkins"
 	"kubesphere.io/devops/pkg/client/k8s"
 	"kubesphere.io/devops/pkg/client/s3"
@@ -67,6 +68,8 @@ func NewDevOpsControllerManagerOptions() *DevOpsControllerManagerOptions {
 		LeaderElect:         false,
 		WebhookCertDir:      "",
 		ApplicationSelector: "",
+		KubernetesOptions:   &k8s.KubernetesOptions{},
+		ArgoCDOption:        &config.ArgoCDOption{},
 	}
 
 	return s
diff --git a/config/crd/bases/gitops.kubesphere.io_applications.yaml b/config/crd/bases/gitops.kubesphere.io_applications.yaml
index 4559d7a..a74ccd7 100644
--- a/config/crd/bases/gitops.kubesphere.io_applications.yaml
+++ b/config/crd/bases/gitops.kubesphere.io_applications.yaml
@@ -2023,8 +2023,6 @@ spec:
               kind:
                 description: Engine is the backend GitOps Solutions type
                 type: string
-            required:
-            - kind
             type: object
           status:
             description: ApplicationStatus represents the status of the Application
@@ -2036,8 +2034,6 @@ spec:
               kind:
                 description: Engine is the backend GitOps Solutions type
                 type: string
-            required:
-            - kind
             type: object
         type: object
     served: true
diff --git a/controllers/gitrepository/pull_request_status_controller.go b/controllers/gitrepository/pull_request_status_controller.go
new file mode 100644
index 0000000..8dca6df
--- /dev/null
+++ b/controllers/gitrepository/pull_request_status_controller.go
@@ -0,0 +1,316 @@
+/*
+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"
+	"strconv"
+	"strings"
+
+	"github.com/go-logr/logr"
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/jenkins-x/go-scm/scm/factory"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"kubesphere.io/devops/pkg/utils/net"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// PullRequestStatusReconciler reconciles a Pipeline build status to the Pull Requests
+type PullRequestStatusReconciler struct {
+	client.Client
+	ExternalAddress string
+
+	log      logr.Logger
+	recorder record.EventRecorder
+}
+
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=webhooks,verbs=get;list;update;patch
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=secrets,verbs=get
+
+// Reconcile is the main entry of this reconciler
+func (r *PullRequestStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
+	result ctrl.Result, err error) {
+	pipelinerun := &v1alpha3.PipelineRun{}
+	if err = r.Get(ctx, req.NamespacedName, pipelinerun); err != nil {
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	if !pipelinerun.Spec.IsMultiBranchPipeline() || pipelinerun.Spec.SCM == nil {
+		return
+	}
+
+	r.log.Info(fmt.Sprintf("start to reconcile %s", req.NamespacedName))
+	var prNumber int
+	if prNumber, err = getPRNumber(pipelinerun.Spec.SCM.RefName); err != nil {
+		err = nil
+		return
+	}
+
+	repoInfo := getRepoInfo(pipelinerun.Spec.PipelineSpec.MultiBranchPipeline)
+	if repoInfo.isInvalid() {
+		return
+	}
+
+	var (
+		token    string
+		username string
+	)
+	if username, token, err = r.getTokenFromSecret(&v1.SecretReference{
+		Name:      repoInfo.tokenId,
+		Namespace: pipelinerun.Namespace,
+	}, ""); err != nil {
+		err = fmt.Errorf("failed to get token, error %v", err)
+		return
+	}
+
+	repo := repoInfo.getRepoPath()
+	r.log.Info(fmt.Sprintf("start sending status to %s with pr %d", repo, prNumber))
+
+	var target string
+	if target, err = r.getExternalPipelineRunAddress(ctx, pipelinerun); err != nil {
+		return
+	}
+
+	maker := NewStatusMaker(repo, token)
+	maker.WithTarget(target).WithPR(prNumber).WithProvider(repoInfo.provider).WithUsername(username)
+
+	err = maker.CreateWithPipelinePhase(ctx, pipelinerun.Status.Phase, "KubeSphere DevOps", string(pipelinerun.Status.Phase))
+	if err != nil {
+		r.log.Error(err, "failed to send status")
+	}
+	return
+}
+
+type repoInfo struct {
+	provider string
+	owner    string
+	repo     string
+	tokenId  string
+}
+
+func (r repoInfo) getRepoPath() string {
+	return fmt.Sprintf("%s/%s", r.owner, r.repo)
+}
+
+func (r repoInfo) isInvalid() bool {
+	return r.provider == "" || r.owner == "" || r.repo == "" || r.tokenId == ""
+}
+
+func getRepoInfo(repo *v1alpha3.MultiBranchPipeline) (info repoInfo) {
+	if repo == nil {
+		return
+	}
+	switch repo.SourceType {
+	case v1alpha3.SourceTypeBitbucket:
+		if repo.BitbucketServerSource != nil {
+			info.provider = "bitbucket"
+			info.owner = repo.BitbucketServerSource.Owner
+			info.repo = repo.BitbucketServerSource.Repo
+			info.tokenId = repo.BitbucketServerSource.CredentialId
+			if repo.BitbucketServerSource.ApiUri == "https://bitbucket.org" {
+				info.provider = "bitbucketcloud"
+			}
+		}
+	case v1alpha3.SourceTypeGithub:
+		if repo.GitHubSource != nil {
+			info.provider = "github"
+			info.owner = repo.GitHubSource.Owner
+			info.repo = repo.GitHubSource.Repo
+			info.tokenId = repo.GitHubSource.CredentialId
+		}
+	case v1alpha3.SourceTypeGitlab:
+		if repo.GitlabSource != nil {
+			info.provider = "gitlab"
+			info.owner = repo.GitlabSource.Owner
+			// the repo format of Gitlab is: owner/repo
+			info.repo = strings.TrimPrefix(repo.GitlabSource.Repo, repo.GitlabSource.Owner+"/")
+			info.tokenId = repo.GitlabSource.CredentialId
+		}
+	}
+	return
+}
+
+func (r *PullRequestStatusReconciler) getExternalPipelineRunAddress(ctx context.Context, pipelineRun *v1alpha3.PipelineRun) (target string, err error) {
+	var ws string
+	if ws, err = r.getWorkspace(ctx, pipelineRun.GetNamespace()); err == nil {
+		target = fmt.Sprintf("%s/%s/clusters/default/devops/%s/pipelines/%s/run/%s/task-status",
+			net.ParseURL(r.ExternalAddress), ws, pipelineRun.Namespace, pipelineRun.Spec.PipelineRef.Name, pipelineRun.Name)
+	}
+	return
+}
+
+func (r *PullRequestStatusReconciler) getWorkspace(ctx context.Context, ns string) (ws string, err error) {
+	project := &v1alpha3.DevOpsProject{}
+	if err = r.Get(ctx, types.NamespacedName{
+		Name: ns,
+	}, project); err == nil {
+		ws = project.GetLabels()["kubesphere.io/workspace"]
+	}
+	return
+}
+
+func (r *PullRequestStatusReconciler) getTokenFromSecret(secretRef *v1.SecretReference, defaultNamespace string) (username, token string, err error) {
+	var gitSecret *v1.Secret
+	if gitSecret, err = r.getSecret(secretRef, defaultNamespace); err == nil {
+		switch gitSecret.Type {
+		case v1.SecretTypeBasicAuth, v1alpha3.SecretTypeBasicAuth:
+			token = string(gitSecret.Data[v1.BasicAuthPasswordKey])
+			username = string(gitSecret.Data[v1.BasicAuthUsernameKey])
+		case v1.SecretTypeOpaque, v1alpha3.SecretTypeSecretText:
+			token = string(gitSecret.Data[v1.ServiceAccountTokenKey])
+		}
+	}
+	return
+}
+
+func (r *PullRequestStatusReconciler) getSecret(ref *v1.SecretReference, defaultNamespace string) (secret *v1.Secret, err error) {
+	secret = &v1.Secret{}
+	ns := ref.Namespace
+	if ns == "" {
+		ns = defaultNamespace
+	}
+
+	if err = r.Client.Get(context.TODO(), types.NamespacedName{
+		Namespace: ns, Name: ref.Name,
+	}, secret); err != nil {
+		err = fmt.Errorf("cannot get secret %v, error is: %v", secret, err)
+	}
+	return
+}
+
+func getPRNumber(pr string) (int, error) {
+	pr = strings.ToLower(pr)
+	pr = strings.TrimPrefix(pr, "pr-")
+	pr = strings.TrimPrefix(pr, "mr-")
+	return strconv.Atoi(pr)
+}
+
+// GetName returns the name of this reconciler
+func (r *PullRequestStatusReconciler) GetName() string {
+	return "pull-request-status-controller"
+}
+
+// GetGroupName returns the gorup name of the set of reconcilers
+func (r *PullRequestStatusReconciler) GetGroupName() string {
+	return groupName
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *PullRequestStatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.recorder = mgr.GetEventRecorderFor(r.GetName())
+	r.log = ctrl.Log.WithName(r.GetName())
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha3.PipelineRun{}).
+		Complete(r)
+}
+
+// StatusMaker responsible for Pull Requests status creating
+type StatusMaker struct {
+	provider string
+	server   string
+	repo     string
+	pr       int
+	token    string
+	username string
+	target   string
+}
+
+// NewStatusMaker creates an instance of statusMaker
+func NewStatusMaker(repo, token string) *StatusMaker {
+	return &StatusMaker{
+		repo:  repo,
+		token: token,
+	}
+}
+
+// WithUsername sets the username
+func (s *StatusMaker) WithUsername(username string) *StatusMaker {
+	s.username = username
+	return s
+}
+
+// WithProvider sets the provider
+func (s *StatusMaker) WithProvider(provider string) *StatusMaker {
+	s.provider = provider
+	return s
+}
+
+// WithProvider sets the server
+func (s *StatusMaker) WithServer(server string) *StatusMaker {
+	s.server = server
+	return s
+}
+
+// WithTarget sets the target URL
+func (s *StatusMaker) WithTarget(target string) *StatusMaker {
+	s.target = target
+	return s
+}
+
+// WithPR sets the pr number
+func (s *StatusMaker) WithPR(pr int) *StatusMaker {
+	s.pr = pr
+	return s
+}
+
+// Create creates a generic status
+func (s *StatusMaker) Create(ctx context.Context, status scm.State, label, desc string) (err error) {
+	var client *scm.Client
+	client, err = factory.NewClient(s.provider, s.server, s.token, func(c *scm.Client) {
+		c.Username = s.username
+	})
+	if err != nil {
+		return
+	}
+
+	var pullRequest *scm.PullRequest
+	if pullRequest, _, err = client.PullRequests.Find(ctx, s.repo, s.pr); err == nil {
+		_, _, err = client.Repositories.CreateStatus(ctx, s.repo, pullRequest.Sha, &scm.StatusInput{
+			Desc:   desc,
+			Label:  label,
+			State:  status,
+			Target: s.target,
+		})
+	}
+	return
+}
+
+// CreateWithPipelinePhase creates a generic status with the PipelineRun phase
+func (s *StatusMaker) CreateWithPipelinePhase(ctx context.Context, phase v1alpha3.RunPhase, label, desc string) (err error) {
+	return s.Create(ctx, convertPipelineRunPhaseToSCMStatus(phase), label, desc)
+}
+
+func convertPipelineRunPhaseToSCMStatus(phase v1alpha3.RunPhase) (status scm.State) {
+	switch phase {
+	case v1alpha3.Pending:
+		status = scm.StatePending
+	case v1alpha3.Failed:
+		status = scm.StateFailure
+	case v1alpha3.Running:
+		status = scm.StateRunning
+	case v1alpha3.Succeeded:
+		status = scm.StateSuccess
+	case v1alpha3.Cancelled:
+		status = scm.StateCanceled
+	default:
+		status = scm.StateUnknown
+	}
+	return status
+}
diff --git a/pkg/api/devops/v1alpha3/pipelinerun_types.go b/pkg/api/devops/v1alpha3/pipelinerun_types.go
index a61c380..4301e13 100644
--- a/pkg/api/devops/v1alpha3/pipelinerun_types.go
+++ b/pkg/api/devops/v1alpha3/pipelinerun_types.go
@@ -238,18 +238,16 @@ type RunPhase string
 const (
 	// Pending indicates that the PipelineRun is pending.
 	Pending RunPhase = "Pending"
-
 	// Running indicates that the PipelineRun is running.
 	Running RunPhase = "Running"
-
 	// Succeeded indicates that the PipelineRun has succeeded.
 	Succeeded RunPhase = "Succeeded"
-
 	// Failed indicates that the PipelineRun has failed.
 	Failed RunPhase = "Failed"
-
 	// Unknown indicates that the PipelineRun has an unknown status.
 	Unknown RunPhase = "Unknown"
+	// Cancelled indicates that the PipelineRun has been cancelled
+	Cancelled RunPhase = "Cancelled"
 )
 
 // ConditionType is type of PipelineRun condition.
diff --git a/pkg/api/gitops/v1alpha1/application.go b/pkg/api/gitops/v1alpha1/application.go
index fccf273..8d45611 100644
--- a/pkg/api/gitops/v1alpha1/application.go
+++ b/pkg/api/gitops/v1alpha1/application.go
@@ -238,7 +238,7 @@ type KustomizationSpec struct {
 
 // ApplicationSpec is the specification of the Application
 type ApplicationSpec struct {
-	Kind    Engine           `json:"kind"`
+	Kind    Engine           `json:"kind,omitempty"`
 	ArgoApp *ArgoApplication `json:"argoApp,omitempty"`
 	FluxApp *FluxApplication `json:"fluxApp,omitempty"`
 }
@@ -586,7 +586,7 @@ type Application struct {
 
 // ApplicationStatus represents the status of the Application
 type ApplicationStatus struct {
-	Kind    Engine `json:"kind"`
+	Kind    Engine `json:"kind,omitempty"`
 	ArgoApp string `json:"argoApp,omitempty"`
 	FluxApp string `json:"fluxApp,omitempty"`
 }
diff --git a/pkg/utils/net/net.go b/pkg/utils/net/net.go
index a6da555..517ed88 100644
--- a/pkg/utils/net/net.go
+++ b/pkg/utils/net/net.go
@@ -47,3 +47,13 @@ func GetRequestIP(req *http.Request) string {
 
 	return address
 }
+
+// ParseURL makes sure return a valid URL string
+func ParseURL(address string) string {
+	address = strings.TrimSuffix(address, "/")
+	if strings.HasPrefix(address, "http://") || strings.HasPrefix(address, "https://") {
+		return address
+	}
+	address = "https://" + address
+	return address
+}


Test patch:
diff --git a/cmd/controller/app/options/feature_test.go b/cmd/controller/app/options/feature_test.go
index 9f7e089..042bc07 100644
--- a/cmd/controller/app/options/feature_test.go
+++ b/cmd/controller/app/options/feature_test.go
@@ -19,6 +19,9 @@ package options
 import (
 	"reflect"
 	"testing"
+
+	"github.com/spf13/pflag"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestFeatureOptions_GetControllers(t *testing.T) {
@@ -91,3 +94,29 @@ func TestFeatureOptions_GetControllers(t *testing.T) {
 		})
 	}
 }
+
+func TestFeatureOptions(t *testing.T) {
+	opt := NewFeatureOptions()
+	assert.NotNil(t, opt)
+	assert.Equal(t, []error{}, opt.Validate())
+
+	opt.Controllers = map[string]bool{
+		"fake": true,
+	}
+	assert.Equal(t, []string{"fake"}, opt.knownControllers())
+
+	opt.ExternalAddress = "fake-address"
+	assert.Equal(t, "fake-address", opt.ExternalAddress)
+
+	newOpt := NewFeatureOptions()
+	assert.Empty(t, newOpt.ExternalAddress)
+	opt.ApplyTo(newOpt)
+	assert.Equal(t, "fake-address", newOpt.ExternalAddress)
+
+	flagSet := &pflag.FlagSet{}
+	opt.AddFlags(flagSet, opt)
+	assert.True(t, flagSet.HasFlags())
+	assert.NotNil(t, flagSet.Lookup("enabled-controllers"))
+	assert.NotNil(t, flagSet.Lookup("system-namespace"))
+	assert.NotNil(t, flagSet.Lookup("external-address"))
+}
diff --git a/cmd/controller/app/options/options_test.go b/cmd/controller/app/options/options_test.go
new file mode 100644
index 0000000..b539f24
--- /dev/null
+++ b/cmd/controller/app/options/options_test.go
@@ -0,0 +1,44 @@
+/*
+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 options
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestOption(t *testing.T) {
+	opt := NewDevOpsControllerManagerOptions()
+	assert.NotNil(t, opt)
+
+	flags := opt.Flags()
+	assert.NotNil(t, flags)
+	assert.NotNil(t, flags.FlagSet("kubernetes"))
+	assert.NotNil(t, flags.FlagSet("devops"))
+	assert.NotNil(t, flags.FlagSet("feature"))
+	assert.NotNil(t, flags.FlagSet("argocd"))
+	assert.NotNil(t, flags.FlagSet("generic"))
+	assert.NotNil(t, flags.FlagSet("leaderelection"))
+	assert.NotNil(t, flags.FlagSet("klog"))
+
+	opt.ApplicationSelector = "key=value"
+	assert.Nil(t, opt.Validate())
+
+	opt.ApplicationSelector = "!@#$"
+	assert.NotNil(t, opt.Validate())
+}
diff --git a/controllers/gitrepository/interface_test.go b/controllers/gitrepository/interface_test.go
index 4ac11b2..8802457 100644
--- a/controllers/gitrepository/interface_test.go
+++ b/controllers/gitrepository/interface_test.go
@@ -17,9 +17,10 @@ limitations under the License.
 package gitrepository
 
 import (
+	"testing"
+
 	"github.com/stretchr/testify/assert"
 	"kubesphere.io/devops/controllers/core"
-	"testing"
 )
 
 func TestInterfaceImplement(t *testing.T) {
@@ -49,6 +50,12 @@ func TestInterfaceImplement(t *testing.T) {
 			NamedReconciler: &WebhookReconciler{},
 			GroupReconciler: &WebhookReconciler{},
 		},
+	}, {
+		name: "PullRequestStatusReconciler",
+		instance: interInstance{
+			NamedReconciler: &PullRequestStatusReconciler{},
+			GroupReconciler: &PullRequestStatusReconciler{},
+		},
 	}}
 	for i := range tests {
 		tt := tests[i]
diff --git a/controllers/gitrepository/pull_request_status_controller_test.go b/controllers/gitrepository/pull_request_status_controller_test.go
new file mode 100644
index 0000000..56b80e2
--- /dev/null
+++ b/controllers/gitrepository/pull_request_status_controller_test.go
@@ -0,0 +1,351 @@
+/*
+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"
+	"testing"
+
+	"github.com/go-logr/logr"
+	"github.com/h2non/gock"
+	"github.com/jenkins-x/go-scm/scm"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	mgrcore "kubesphere.io/devops/controllers/core"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+var mockHeaders = map[string]string{
+	"X-GitHub-Request-Id":   "DD0E:6011:12F21A8:1926790:5A2064E2",
+	"X-RateLimit-Limit":     "60",
+	"X-RateLimit-Remaining": "59",
+	"X-RateLimit-Reset":     "1512076018",
+}
+
+func TestCreatePullRequestStatus(t *testing.T) {
+	defer gock.Off()
+
+	gock.New("https://api.github.com").
+		Get("/repos/octocat/hello-world/pulls/1347").
+		Reply(200).
+		Type("application/json").
+		SetHeaders(mockHeaders).
+		File("testdata/pr.json")
+
+	gock.New("https://api.github.com").
+		Post("/repos/octocat/hello-world/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e").
+		Reply(201).
+		Type("application/json").
+		SetHeaders(mockHeaders).
+		File("testdata/status.json")
+
+	maker := NewStatusMaker("octocat/hello-world", "")
+	maker.WithTarget("https://ci.example.com/1000/output").WithPR(1347)
+
+	err := maker.Create(context.Background(), scm.StateSuccess, "continuous-integration/drone", "Build has completed successfully")
+	assert.Nil(t, err)
+
+	maker.WithProvider("fake-provider").WithServer("fake-server")
+	err = maker.Create(context.Background(), scm.StateSuccess, "continuous-integration/drone", "Build has completed successfully")
+	assert.NotNil(t, err)
+}
+
+func TestPullRequestStatusReconciler_SetupWithManager(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &PullRequestStatusReconciler{
+				Client:   tt.fields.Client,
+				log:      tt.fields.log,
+				recorder: tt.fields.recorder,
+			}
+			mgr := &mgrcore.FakeManager{
+				Client: tt.fields.Client,
+				Scheme: schema,
+			}
+			tt.wantErr(t, r.SetupWithManager(mgr), fmt.Sprintf("SetupWithManager(%v)", mgr))
+		})
+	}
+}
+
+func TestPullRequestStatusReconciler(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type request struct {
+		name, namespace string
+	}
+	defaultReq := request{namespace: "ns", name: "fake"}
+
+	secret := &v1.Secret{}
+	secret.SetName("token")
+	secret.SetNamespace(defaultReq.namespace)
+	secret.Type = v1.SecretTypeBasicAuth
+	secret.Data = map[string][]byte{
+		v1.BasicAuthPasswordKey: []byte(""),
+	}
+
+	pipRun := &v1alpha3.PipelineRun{}
+	pipRun.SetName(defaultReq.name)
+	pipRun.SetNamespace(defaultReq.namespace)
+	pipRun.Spec = v1alpha3.PipelineRunSpec{
+		SCM: &v1alpha3.SCM{
+			RefName: "PR-1347",
+		},
+		PipelineRef: &v1.ObjectReference{
+			Name: "pipeline",
+		},
+		PipelineSpec: &v1alpha3.PipelineSpec{
+			Type: "multi-branch-pipeline",
+			MultiBranchPipeline: &v1alpha3.MultiBranchPipeline{
+				SourceType: v1alpha3.SourceTypeGithub,
+				GitHubSource: &v1alpha3.GithubSource{
+					CredentialId: "token",
+					Owner:        "octocat",
+					Repo:         "hello-world",
+				},
+			},
+		},
+	}
+	pipRun.Status.Phase = "Succeeded"
+
+	project := &v1alpha3.DevOpsProject{}
+	project.SetName(defaultReq.namespace)
+	project.Labels = map[string]string{
+		"kubesphere.io/workspace": "ws",
+	}
+
+	tests := []struct {
+		name       string
+		request    request
+		prepare    func(*testing.T)
+		k8sClient  client.Client
+		wantResult ctrl.Result
+		wantErr    bool
+	}{{
+		name:      "not found pipelinerun",
+		request:   defaultReq,
+		k8sClient: fake.NewClientBuilder().WithScheme(schema).Build(),
+
+		wantErr: false,
+	}, {
+		name:    "pipeline with github",
+		request: defaultReq,
+		prepare: func(t *testing.T) {
+			gock.New("https://api.github.com").
+				Get("/repos/octocat/hello-world/pulls/1347").
+				Reply(200).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				File("testdata/pr.json")
+
+			gock.New("https://api.github.com").
+				Post("/repos/octocat/hello-world/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e").
+				Reply(201).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				File("testdata/status.json")
+		},
+		k8sClient: fake.NewClientBuilder().WithScheme(schema).WithRuntimeObjects(pipRun.DeepCopy(), secret.DeepCopy(), project.DeepCopy()).Build(),
+		wantErr:   false,
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			defer gock.Off()
+
+			if tt.prepare != nil {
+				tt.prepare(t)
+			}
+			recon := &PullRequestStatusReconciler{
+				log:    logr.New(log.NullLogSink{}),
+				Client: tt.k8sClient,
+			}
+
+			result, err := recon.Reconcile(context.Background(), ctrl.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: tt.request.namespace,
+					Name:      tt.request.name,
+				},
+			})
+
+			assert.Equal(t, tt.wantResult, result)
+			if tt.wantErr {
+				assert.NotNil(t, err, "should have error in case [%s]-[%d]", tt.name, i)
+			} else {
+				assert.Nil(t, err, "should not have error in case [%s]-[%d]", tt.name, i)
+			}
+		})
+	}
+}
+
+func Test(t *testing.T) {
+	tests := []struct {
+		name    string
+		pr      string
+		wantNum int
+		wantErr bool
+	}{{
+		name:    "lower case",
+		pr:      "pr-1",
+		wantNum: 1,
+		wantErr: false,
+	}, {
+		name:    "lower and upper case",
+		pr:      "Pr-1",
+		wantNum: 1,
+		wantErr: false,
+	}, {
+		name:    "upper case",
+		pr:      "PR-1",
+		wantNum: 1,
+		wantErr: false,
+	}, {
+		name:    "upper case with MR",
+		pr:      "MR-3",
+		wantNum: 3,
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			num, err := getPRNumber(tt.pr)
+			assert.Equal(t, tt.wantNum, num)
+			if tt.wantErr {
+				assert.NotNil(t, err, "should have error in case [%d]-[%s]", i, tt.name)
+			} else {
+
+				assert.Nil(t, err, "should not have error in case [%d]-[%s]", i, tt.name)
+			}
+		})
+	}
+}
+
+func TestConvertPipelineRunPhaseToSCMStatus(t *testing.T) {
+	tests := []struct {
+		name       string
+		phase      v1alpha3.RunPhase
+		wantStatus scm.State
+	}{{
+		name:       "success",
+		phase:      v1alpha3.Succeeded,
+		wantStatus: scm.StateSuccess,
+	}, {
+		name:       "cancelled",
+		phase:      v1alpha3.Cancelled,
+		wantStatus: scm.StateCanceled,
+	}, {
+		name:       "failure",
+		phase:      v1alpha3.Failed,
+		wantStatus: scm.StateFailure,
+	}, {
+		name:       "pendding",
+		phase:      v1alpha3.Pending,
+		wantStatus: scm.StatePending,
+	}, {
+		name:       "unknown",
+		phase:      v1alpha3.Unknown,
+		wantStatus: scm.StateUnknown,
+	}, {
+		name:       "running",
+		phase:      v1alpha3.Running,
+		wantStatus: scm.StateRunning,
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			status := convertPipelineRunPhaseToSCMStatus(tt.phase)
+			assert.Equal(t, tt.wantStatus, status, "failed in case [%d]", i)
+		})
+	}
+}
+
+func TestGetRepoInfo(t *testing.T) {
+	emptyRepoInfo := repoInfo{}
+
+	tests := []struct {
+		name     string
+		repo     *v1alpha3.MultiBranchPipeline
+		wantInfo repoInfo
+	}{{
+		name:     "repo is nil",
+		repo:     nil,
+		wantInfo: emptyRepoInfo,
+	}, {
+		name: "github",
+		repo: &v1alpha3.MultiBranchPipeline{
+			SourceType: v1alpha3.SourceTypeGithub,
+			GitHubSource: &v1alpha3.GithubSource{
+				Owner:        "owner",
+				Repo:         "repo",
+				CredentialId: "token",
+			},
+		},
+		wantInfo: repoInfo{owner: "owner", repo: "repo", tokenId: "token", provider: "github"},
+	}, {
+		name: "gitlab",
+		repo: &v1alpha3.MultiBranchPipeline{
+			SourceType: v1alpha3.SourceTypeGitlab,
+			GitlabSource: &v1alpha3.GitlabSource{
+				Owner:        "owner",
+				Repo:         "repo",
+				CredentialId: "token",
+			},
+		},
+		wantInfo: repoInfo{owner: "owner", repo: "repo", tokenId: "token", provider: "gitlab"},
+	}, {
+		name: "bitbucket",
+		repo: &v1alpha3.MultiBranchPipeline{
+			SourceType: v1alpha3.SourceTypeBitbucket,
+			BitbucketServerSource: &v1alpha3.BitbucketServerSource{
+				ApiUri:       "https://bitbucket.org",
+				Owner:        "owner",
+				Repo:         "repo",
+				CredentialId: "token",
+			},
+		},
+		wantInfo: repoInfo{owner: "owner", repo: "repo", tokenId: "token", provider: "bitbucketcloud"},
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			info := getRepoInfo(tt.repo)
+			assert.Equal(t, tt.wantInfo, info, "failed in case [%d]", i)
+		})
+	}
+}
diff --git a/controllers/gitrepository/testdata/pr.json b/controllers/gitrepository/testdata/pr.json
new file mode 100644
index 0000000..712ec1f
--- /dev/null
+++ b/controllers/gitrepository/testdata/pr.json
@@ -0,0 +1,414 @@
+{
+    "id": 1,
+    "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347",
+    "html_url": "https://github.com/octocat/Hello-World/pull/1347",
+    "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff",
+    "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch",
+    "issue_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347",
+    "commits_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits",
+    "review_comments_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments",
+    "review_comment_url": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}",
+    "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments",
+    "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e",
+    "number": 1347,
+    "state": "open",
+    "title": "new-feature",
+    "body": "Please pull these awesome changes",
+    "labels": [
+      {
+        "id": 208045946,
+        "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=",
+        "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug",
+        "name": "bug",
+        "description": "Something isn't working",
+        "color": "f29513",
+        "default": true
+      }
+    ],
+    "assignee": {
+        "login": "octocat",
+        "id": 1,
+        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+        "gravatar_id": "",
+        "url": "https://api.github.com/users/octocat",
+        "html_url": "https://github.com/octocat",
+        "followers_url": "https://api.github.com/users/octocat/followers",
+        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+        "organizations_url": "https://api.github.com/users/octocat/orgs",
+        "repos_url": "https://api.github.com/users/octocat/repos",
+        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+        "received_events_url": "https://api.github.com/users/octocat/received_events",
+        "type": "User",
+        "site_admin": false
+    },
+    "milestone": {
+        "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1",
+        "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0",
+        "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels",
+        "id": 1002604,
+        "number": 1,
+        "state": "open",
+        "title": "v1.0",
+        "description": "Tracking milestone for version 1.0",
+        "creator": {
+            "login": "octocat",
+            "id": 1,
+            "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+            "gravatar_id": "",
+            "url": "https://api.github.com/users/octocat",
+            "html_url": "https://github.com/octocat",
+            "followers_url": "https://api.github.com/users/octocat/followers",
+            "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+            "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+            "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+            "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+            "organizations_url": "https://api.github.com/users/octocat/orgs",
+            "repos_url": "https://api.github.com/users/octocat/repos",
+            "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+            "received_events_url": "https://api.github.com/users/octocat/received_events",
+            "type": "User",
+            "site_admin": false
+        },
+        "open_issues": 4,
+        "closed_issues": 8,
+        "created_at": "2011-04-10T20:09:31Z",
+        "updated_at": "2014-03-03T18:58:10Z",
+        "closed_at": "2013-02-12T13:22:01Z",
+        "due_on": "2012-10-09T23:39:01Z"
+    },
+    "locked": false,
+    "created_at": "2011-01-26T19:01:12Z",
+    "updated_at": "2011-01-26T19:01:12Z",
+    "closed_at": "2011-01-26T19:01:12Z",
+    "merged_at": "2011-01-26T19:01:12Z",
+    "head": {
+        "label": "new-topic",
+        "ref": "new-topic",
+        "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
+        "user": {
+            "login": "octocat",
+            "id": 1,
+            "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+            "gravatar_id": "",
+            "url": "https://api.github.com/users/octocat",
+            "html_url": "https://github.com/octocat",
+            "followers_url": "https://api.github.com/users/octocat/followers",
+            "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+            "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+            "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+            "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+            "organizations_url": "https://api.github.com/users/octocat/orgs",
+            "repos_url": "https://api.github.com/users/octocat/repos",
+            "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+            "received_events_url": "https://api.github.com/users/octocat/received_events",
+            "type": "User",
+            "site_admin": false
+        },
+        "repo": {
+            "id": 1296269,
+            "owner": {
+                "login": "octocat",
+                "id": 1,
+                "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+                "gravatar_id": "",
+                "url": "https://api.github.com/users/octocat",
+                "html_url": "https://github.com/octocat",
+                "followers_url": "https://api.github.com/users/octocat/followers",
+                "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+                "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+                "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+                "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+                "organizations_url": "https://api.github.com/users/octocat/orgs",
+                "repos_url": "https://api.github.com/users/octocat/repos",
+                "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+                "received_events_url": "https://api.github.com/users/octocat/received_events",
+                "type": "User",
+                "site_admin": false
+            },
+            "name": "Hello-World",
+            "full_name": "octocat/Hello-World",
+            "description": "This your first repo!",
+            "private": false,
+            "fork": true,
+            "url": "https://api.github.com/repos/octocat/Hello-World",
+            "html_url": "https://github.com/octocat/Hello-World",
+            "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}",
+            "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}",
+            "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}",
+            "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}",
+            "clone_url": "https://github.com/octocat/Hello-World.git",
+            "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}",
+            "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}",
+            "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}",
+            "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}",
+            "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}",
+            "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors",
+            "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments",
+            "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads",
+            "events_url": "http://api.github.com/repos/octocat/Hello-World/events",
+            "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks",
+            "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}",
+            "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}",
+            "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}",
+            "git_url": "git:github.com/octocat/Hello-World.git",
+            "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks",
+            "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}",
+            "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}",
+            "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}",
+            "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}",
+            "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}",
+            "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages",
+            "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges",
+            "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}",
+            "mirror_url": "git:git.example.com/octocat/Hello-World",
+            "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since, all, participating}",
+            "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}",
+            "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}",
+            "ssh_url": "git@github.com:octocat/Hello-World.git",
+            "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers",
+            "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}",
+            "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers",
+            "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription",
+            "svn_url": "https://svn.github.com/octocat/Hello-World",
+            "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags",
+            "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams",
+            "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}",
+            "homepage": "https://github.com",
+            "language": null,
+            "forks_count": 9,
+            "stargazers_count": 80,
+            "watchers_count": 80,
+            "size": 108,
+            "default_branch": "master",
+            "open_issues_count": 0,
+            "topics": [
+                "octocat",
+                "atom",
+                "electron",
+                "API"
+            ],
+            "has_issues": true,
+            "has_wiki": true,
+            "has_pages": false,
+            "has_downloads": true,
+            "archived": false,
+            "pushed_at": "2011-01-26T19:06:43Z",
+            "created_at": "2011-01-26T19:01:12Z",
+            "updated_at": "2011-01-26T19:14:43Z",
+            "permissions": {
+                "admin": false,
+                "push": false,
+                "pull": true
+            },
+            "allow_rebase_merge": true,
+            "allow_squash_merge": true,
+            "allow_merge_commit": true,
+            "subscribers_count": 42,
+            "network_count": 0
+        }
+    },
+    "base": {
+        "label": "master",
+        "ref": "master",
+        "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
+        "user": {
+            "login": "octocat",
+            "id": 1,
+            "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+            "gravatar_id": "",
+            "url": "https://api.github.com/users/octocat",
+            "html_url": "https://github.com/octocat",
+            "followers_url": "https://api.github.com/users/octocat/followers",
+            "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+            "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+            "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+            "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+            "organizations_url": "https://api.github.com/users/octocat/orgs",
+            "repos_url": "https://api.github.com/users/octocat/repos",
+            "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+            "received_events_url": "https://api.github.com/users/octocat/received_events",
+            "type": "User",
+            "site_admin": false
+        },
+        "repo": {
+            "id": 1296269,
+            "owner": {
+                "login": "octocat",
+                "id": 1,
+                "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+                "gravatar_id": "",
+                "url": "https://api.github.com/users/octocat",
+                "html_url": "https://github.com/octocat",
+                "followers_url": "https://api.github.com/users/octocat/followers",
+                "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+                "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+                "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+                "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+                "organizations_url": "https://api.github.com/users/octocat/orgs",
+                "repos_url": "https://api.github.com/users/octocat/repos",
+                "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+                "received_events_url": "https://api.github.com/users/octocat/received_events",
+                "type": "User",
+                "site_admin": false
+            },
+            "name": "Hello-World",
+            "full_name": "octocat/Hello-World",
+            "description": "This your first repo!",
+            "private": false,
+            "fork": true,
+            "url": "https://api.github.com/repos/octocat/Hello-World",
+            "html_url": "https://github.com/octocat/Hello-World",
+            "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}",
+            "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}",
+            "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}",
+            "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}",
+            "clone_url": "https://github.com/octocat/Hello-World.git",
+            "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}",
+            "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}",
+            "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}",
+            "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}",
+            "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}",
+            "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors",
+            "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments",
+            "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads",
+            "events_url": "http://api.github.com/repos/octocat/Hello-World/events",
+            "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks",
+            "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}",
+            "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}",
+            "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}",
+            "git_url": "git:github.com/octocat/Hello-World.git",
+            "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks",
+            "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}",
+            "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}",
+            "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}",
+            "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}",
+            "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}",
+            "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages",
+            "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges",
+            "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}",
+            "mirror_url": "git:git.example.com/octocat/Hello-World",
+            "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since, all, participating}",
+            "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}",
+            "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}",
+            "ssh_url": "git@github.com:octocat/Hello-World.git",
+            "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers",
+            "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}",
+            "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers",
+            "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription",
+            "svn_url": "https://svn.github.com/octocat/Hello-World",
+            "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags",
+            "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams",
+            "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}",
+            "homepage": "https://github.com",
+            "language": null,
+            "forks_count": 9,
+            "stargazers_count": 80,
+            "watchers_count": 80,
+            "size": 108,
+            "default_branch": "master",
+            "open_issues_count": 0,
+            "topics": [
+                "octocat",
+                "atom",
+                "electron",
+                "API"
+            ],
+            "has_issues": true,
+            "has_wiki": true,
+            "has_pages": false,
+            "has_downloads": true,
+            "archived": false,
+            "pushed_at": "2011-01-26T19:06:43Z",
+            "created_at": "2011-01-26T19:01:12Z",
+            "updated_at": "2011-01-26T19:14:43Z",
+            "permissions": {
+                "admin": false,
+                "push": false,
+                "pull": true
+            },
+            "allow_rebase_merge": true,
+            "allow_squash_merge": true,
+            "allow_merge_commit": true,
+            "subscribers_count": 42,
+            "network_count": 0
+        }
+    },
+    "_links": {
+        "self": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347"
+        },
+        "html": {
+            "href": "https://github.com/octocat/Hello-World/pull/1347"
+        },
+        "issue": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347"
+        },
+        "comments": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments"
+        },
+        "review_comments": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments"
+        },
+        "review_comment": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}"
+        },
+        "commits": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits"
+        },
+        "statuses": {
+            "href": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e"
+        }
+    },
+    "user": {
+        "login": "octocat",
+        "id": 1,
+        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+        "gravatar_id": "",
+        "url": "https://api.github.com/users/octocat",
+        "html_url": "https://github.com/octocat",
+        "followers_url": "https://api.github.com/users/octocat/followers",
+        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+        "organizations_url": "https://api.github.com/users/octocat/orgs",
+        "repos_url": "https://api.github.com/users/octocat/repos",
+        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+        "received_events_url": "https://api.github.com/users/octocat/received_events",
+        "type": "User",
+        "site_admin": false
+    },
+    "merge_commit_sha": "e5bd3914e2e596debea16f433f57875b5b90bcd6",
+    "merged": false,
+    "mergeable": true,
+    "rebaseable": true,
+    "mergeable_state": "clean",
+    "merged_by": {
+        "login": "octocat",
+        "id": 1,
+        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+        "gravatar_id": "",
+        "url": "https://api.github.com/users/octocat",
+        "html_url": "https://github.com/octocat",
+        "followers_url": "https://api.github.com/users/octocat/followers",
+        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+        "organizations_url": "https://api.github.com/users/octocat/orgs",
+        "repos_url": "https://api.github.com/users/octocat/repos",
+        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+        "received_events_url": "https://api.github.com/users/octocat/received_events",
+        "type": "User",
+        "site_admin": false
+    },
+    "comments": 10,
+    "commits": 3,
+    "additions": 100,
+    "deletions": 3,
+    "changed_files": 5,
+    "maintainer_can_modify": true
+}
diff --git a/controllers/gitrepository/testdata/status.json b/controllers/gitrepository/testdata/status.json
new file mode 100644
index 0000000..1318e56
--- /dev/null
+++ b/controllers/gitrepository/testdata/status.json
@@ -0,0 +1,29 @@
+{
+    "created_at": "2012-07-20T01:19:13Z",
+    "updated_at": "2012-07-20T01:19:13Z",
+    "state": "success",
+    "target_url": "https://ci.example.com/1000/output",
+    "description": "Build has completed successfully",
+    "id": 1,
+    "url": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e",
+    "context": "continuous-integration/drone",
+    "creator": {
+        "login": "octocat",
+        "id": 1,
+        "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+        "gravatar_id": "",
+        "url": "https://api.github.com/users/octocat",
+        "html_url": "https://github.com/octocat",
+        "followers_url": "https://api.github.com/users/octocat/followers",
+        "following_url": "https://api.github.com/users/octocat/following{/other_user}",
+        "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
+        "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
+        "subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
+        "organizations_url": "https://api.github.com/users/octocat/orgs",
+        "repos_url": "https://api.github.com/users/octocat/repos",
+        "events_url": "https://api.github.com/users/octocat/events{/privacy}",
+        "received_events_url": "https://api.github.com/users/octocat/received_events",
+        "type": "User",
+        "site_admin": false
+    }
+}
diff --git a/pkg/client/git/client_test.go b/pkg/client/git/client_test.go
index a1a2eca..4de7c65 100644
--- a/pkg/client/git/client_test.go
+++ b/pkg/client/git/client_test.go
@@ -18,6 +18,9 @@ package git
 
 import (
 	"fmt"
+	"strings"
+	"testing"
+
 	"github.com/jenkins-x/go-scm/scm"
 	"github.com/stretchr/testify/assert"
 	v1 "k8s.io/api/core/v1"
@@ -26,8 +29,6 @@ import (
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
-	"strings"
-	"testing"
 )
 
 func TestGetClient(t *testing.T) {
@@ -80,6 +81,7 @@ func TestGetClient(t *testing.T) {
 		provider  string
 		secretRef *v1.SecretReference
 		k8sClient client.Client
+		server    string
 	}
 	type args struct {
 		repo *v1alpha3.GitRepository
@@ -162,10 +164,32 @@ func TestGetClient(t *testing.T) {
 			assert.Nil(t, err, i)
 			return false
 		},
+	}, {
+		name: "bitbucket_cloud",
+		fields: fields{
+			provider: "bitbucket_cloud",
+		},
+		wantErr: func(tt assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return false
+		},
+	}, {
+		name: "bitbucket-server",
+		fields: fields{
+			provider: "bitbucket-server",
+			server:   "https://api.bitbucket.org",
+		},
+		wantErr: func(tt assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return false
+		},
 	}}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			r := NewClientFactory(tt.fields.provider, tt.fields.secretRef, tt.fields.k8sClient)
+			if tt.fields.server != "" {
+				r.Server = tt.fields.server
+			}
 			gotClient, err := r.GetClient()
 			if !tt.wantErr(t, err, fmt.Sprintf("GetClient() %s", tt.name)) {
 				return
diff --git a/pkg/utils/net/net_test.go b/pkg/utils/net/net_test.go
index 8a8875e..3241e8e 100644
--- a/pkg/utils/net/net_test.go
+++ b/pkg/utils/net/net_test.go
@@ -19,6 +19,8 @@ package net
 import (
 	"net/http"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestIsValidPort(t *testing.T) {
@@ -96,3 +98,29 @@ func TestGetRequestIP(t *testing.T) {
 		})
 	}
 }
+
+func TestParseURL(t *testing.T) {
+	tests := []struct {
+		name    string
+		address string
+		wantURL string
+	}{{
+		name:    "ip and port",
+		address: "192.168.1.1:8080",
+		wantURL: "https://192.168.1.1:8080",
+	}, {
+		name:    "start with http",
+		address: "http://localhost/",
+		wantURL: "http://localhost",
+	}, {
+		name:    "start with https",
+		address: "https://localhost/",
+		wantURL: "https://localhost",
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := ParseURL(tt.address)
+			assert.Equal(t, tt.wantURL, result, "failed in case [%d]", i)
+		})
+	}
+}