Repository: kubesphere/ks-devops
Pull request #818 (2022-09-06 12:21:52)

Avoid pipeline running status order override
<!-- 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 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 bug

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

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

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

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

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

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


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

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

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

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

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

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["linuxsuren"]} -->
codecov[bot]: # [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/818?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#818](https://codecov.io/gh/kubesphere/ks-devops/pull/818?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (2a37e25) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/5975a3e746c246cbd3be24fe104e3ff0f77b30f2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (5975a3e) will **decrease** coverage by `0.65%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master     #818      +/-   ##
==========================================
- Coverage   56.25%   55.60%   -0.66%     
==========================================
  Files         183      184       +1     
  Lines       10919    14927    +4008     
==========================================
+ Hits         6143     8300    +2157     
- Misses       4407     6252    +1845     
- Partials      369      375       +6     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `55.60% <100.00%> (-0.66%)` | :arrow_down: |

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/818?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [.../gitrepository/gitrepository\_webhook\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvZ2l0cmVwb3NpdG9yeS9naXRyZXBvc2l0b3J5X3dlYmhvb2tfY29udHJvbGxlci5nbw==) | `95.85% <100.00%> (+1.64%)` | :arrow_up: |
| [...rs/gitrepository/pull\_request\_status\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvZ2l0cmVwb3NpdG9yeS9wdWxsX3JlcXVlc3Rfc3RhdHVzX2NvbnRyb2xsZXIuZ28=) | `90.90% <100.00%> (+4.70%)` | :arrow_up: |
| [pkg/utils/stringutils/error.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3V0aWxzL3N0cmluZ3V0aWxzL2Vycm9yLmdv) | `100.00% <100.00%> (ø)` | |
| [pkg/apiserver/runtime/runtime.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaXNlcnZlci9ydW50aW1lL3J1bnRpbWUuZ28=) | `46.42% <0.00%> (-9.83%)` | :arrow_down: |
| [...g/kapis/devops/v1alpha3/pipelinerun/listhandler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMy9waXBlbGluZXJ1bi9saXN0aGFuZGxlci5nbw==) | `61.53% <0.00%> (-5.13%)` | :arrow_down: |
| [.../jenkins/devopsproject/devopsproject\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9kZXZvcHNwcm9qZWN0L2Rldm9wc3Byb2plY3RfY29udHJvbGxlci5nbw==) | `38.82% <0.00%> (-4.41%)` | :arrow_down: |
| [...pis/devops/v1alpha3/webhook/workflowrun\_handler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMy93ZWJob29rL3dvcmtmbG93cnVuX2hhbmRsZXIuZ28=) | `75.75% <0.00%> (-4.25%)` | :arrow_down: |
| [...s/jenkins/pipeline/pipeline\_metadata\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZS9waXBlbGluZV9tZXRhZGF0YV9jb250cm9sbGVyLmdv) | `39.00% <0.00%> (-3.67%)` | :arrow_down: |
| [controllers/predicate/label.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvcHJlZGljYXRlL2xhYmVsLmdv) | `71.42% <0.00%> (-3.58%)` | :arrow_down: |
| [pkg/kapis/utils.go](https://codecov.io/gh/kubesphere/ks-devops/pull/818/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL3V0aWxzLmdv) | `50.00% <0.00%> (-3.58%)` | :arrow_down: |
| ... and [176 more](https://codecov.io/gh/kubesphere/ks-devops/pull/818/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/.github/Jenkinsfile b/.github/Jenkinsfile
index ba81d22..bc0adfe 100644
--- a/.github/Jenkinsfile
+++ b/.github/Jenkinsfile
@@ -5,6 +5,10 @@ pipeline {
     }
   }
 
+  options {
+      parallelsAlwaysFailFast()
+  }
+
   stages {
     stage('Code check') {
       parallel {
@@ -30,6 +34,14 @@ pipeline {
           steps {
             container('go') {
               sh 'make test'
+
+              withCredentials([string(credentialsId: 'codecov', variable: 'VARIABLE')]) {
+                sh '''
+                curl -Os https://uploader.codecov.io/v0.3.1/linux/codecov
+                chmod +x codecov
+                ./codecov -t ${VARIABLE} --flags unittests --name codecov-umbrella --nonZero --pr $CHANGE_ID --file coverage.out
+                '''
+              }
             }
           }
         }
@@ -45,13 +57,26 @@ pipeline {
       }
     }
 
+    stage('Docker Login') {
+      when {
+        changeRequest target: 'master'
+      }
+      steps {
+        container('base') {
+          withCredentials([usernamePassword(credentialsId: 'gh', passwordVariable: 'PASSWORDVARIABLE' ,usernameVariable : 'USERNAMEVARIABLE')]) {
+            sh 'docker login ghcr.io -u$USERNAMEVARIABLE -p$PASSWORDVARIABLE'
+          }
+        }
+      }
+    }
+
     stage('Build Image') {
       parallel {
         stage('Build') {
           agent none
           steps {
             container('base') {
-              sh 'docker build . -f config/dockerfiles/apiserver/Dockerfile -t surenpi/test:latest'
+              sh 'docker build . -f config/dockerfiles/apiserver/Dockerfile -t ghcr.io/kubesphere-sigs/devops-apiserver:pr-$CHANGE_ID'
             }
           }
         }
@@ -60,7 +85,7 @@ pipeline {
           agent none
           steps {
             container('base') {
-              sh 'docker build . -f config/dockerfiles/controller-manager/Dockerfile -t surenpi/test:latest'
+              sh 'docker build . -f config/dockerfiles/controller-manager/Dockerfile -t ghcr.io/kubesphere-sigs/devops-controller:pr-$CHANGE_ID'
             }
           }
         }
@@ -69,11 +94,25 @@ pipeline {
           agent none
           steps {
             container('base') {
-              sh 'docker build . -f config/dockerfiles/tools/Dockerfile -t surenpi/test:latest'
+              sh 'docker build . -f config/dockerfiles/tools/Dockerfile -t ghcr.io/kubesphere-sigs/devops-tools:pr-$CHANGE_ID'
             }
           }
         }
+      }
+    }
 
+    stage('Push Image') {
+      when {
+        changeRequest target: 'master'
+      }
+      steps {
+        container('base') {
+          sh 'docker push ghcr.io/kubesphere-sigs/devops-apiserver:pr-$CHANGE_ID'
+
+          sh 'docker push ghcr.io/kubesphere-sigs/devops-controller:pr-$CHANGE_ID'
+
+          sh 'docker push ghcr.io/kubesphere-sigs/devops-tools:pr-$CHANGE_ID'
+        }
       }
     }
   }
diff --git a/controllers/gitrepository/gitrepository_webhook_controller.go b/controllers/gitrepository/gitrepository_webhook_controller.go
index 40084c7..498e4a7 100644
--- a/controllers/gitrepository/gitrepository_webhook_controller.go
+++ b/controllers/gitrepository/gitrepository_webhook_controller.go
@@ -62,20 +62,18 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ct
 	}
 
 	// make links between the webhook and git repositories
-	if err = r.linkToWebhooks(repo); err != nil {
-		return
-	}
-
-	secret := repo.Spec.Secret
-	if secret == nil {
-		result = ctrl.Result{
-			Requeue:      true,
-			RequeueAfter: time.Minute,
+	if err = r.linkToWebhooks(repo); err == nil {
+		secret := repo.Spec.Secret
+		if secret == nil {
+			result = ctrl.Result{
+				Requeue:      true,
+				RequeueAfter: time.Minute,
+			}
+			return
 		}
-		return
-	}
 
-	err = r.createOrUpdateWebhook(repo)
+		err = r.createOrUpdateWebhook(repo)
+	}
 	return
 }
 
diff --git a/controllers/gitrepository/pull_request_status_controller.go b/controllers/gitrepository/pull_request_status_controller.go
index 5d8b96b..719a86b 100644
--- a/controllers/gitrepository/pull_request_status_controller.go
+++ b/controllers/gitrepository/pull_request_status_controller.go
@@ -16,6 +16,8 @@ package gitrepository
 import (
 	"context"
 	"fmt"
+	"kubesphere.io/devops/pkg/utils/stringutils"
+	"regexp"
 	"strconv"
 	"strings"
 
@@ -53,7 +55,7 @@ func (r *PullRequestStatusReconciler) Reconcile(ctx context.Context, req ctrl.Re
 		return
 	}
 
-	if !pipelinerun.Spec.IsMultiBranchPipeline() || pipelinerun.Spec.SCM == nil {
+	if !pipelinerun.Spec.IsMultiBranchPipeline() || pipelinerun.Spec.SCM == nil || pipelinerun.Status.Phase == "" {
 		return
 	}
 
@@ -91,6 +93,7 @@ func (r *PullRequestStatusReconciler) Reconcile(ctx context.Context, req ctrl.Re
 
 	maker := NewStatusMaker(repo, token)
 	maker.WithTarget(target).WithPR(prNumber).WithProvider(repoInfo.provider).WithUsername(username)
+	maker.WithExpirationCheck(createExpirationCheckFunc(ctx, r, pipelinerun.DeepCopy()))
 
 	err = maker.CreateWithPipelinePhase(ctx, pipelinerun.Status.Phase, "KubeSphere DevOps", string(pipelinerun.Status.Phase))
 	if err != nil {
@@ -99,22 +102,66 @@ func (r *PullRequestStatusReconciler) Reconcile(ctx context.Context, req ctrl.Re
 	return
 }
 
-type repoInfo struct {
+// createExpirationCheckFunc checks the start time of the PipelineRun
+func createExpirationCheckFunc(ctx context.Context, k8sClient client.Client, currentPipelineRun *v1alpha3.PipelineRun) expirationCheckFunc {
+	return func(previousStatus *scm.Status, currentStatus *scm.StatusInput) bool {
+		if previousStatus == nil {
+			// consider this is the first build status
+			return false
+		}
+
+		name, ns, err := getPipelineRunNameAndNsFromURL(previousStatus.Target)
+		if err == nil {
+			if name == currentPipelineRun.Name && ns == currentPipelineRun.Namespace {
+				// allow it pass if they belong to the same PipelineRun
+				return false
+			}
+
+			previousPipelineRun := &v1alpha3.PipelineRun{}
+			if err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, previousPipelineRun); err == nil &&
+				previousPipelineRun.Status.StartTime != nil &&
+				currentPipelineRun.Status.StartTime != nil {
+				return !previousPipelineRun.Status.StartTime.Before(currentPipelineRun.Status.StartTime)
+			}
+		}
+		return false
+	}
+}
+
+// getPipelineRunNameAndNsFromURL parse a URL and returns the name and namespace of a PipelineRun
+//
+// http://ip:port/ks-devops-core/clusters/host/devops/core58fgv/pipelines/ks-devops/branch/PR-816/run/ks-devops-pzdcz/task-status
+func getPipelineRunNameAndNsFromURL(link string) (name, ns string, err error) {
+	var reg *regexp.Regexp
+	if reg, err = regexp.Compile("run/.*/task-status"); err == nil {
+		name = reg.FindString(link)
+		name = strings.TrimPrefix(name, "run/")
+		name = strings.TrimSuffix(name, "/task-status")
+	}
+	if reg, err = regexp.Compile("devops/core58fgv/pipelines"); err == nil {
+		ns = reg.FindString(link)
+		ns = strings.TrimPrefix(ns, "devops/")
+		ns = strings.TrimSuffix(ns, "/pipelines")
+	}
+	return
+}
+
+type repoInformation struct {
 	provider string
 	owner    string
 	repo     string
 	tokenId  string
 }
 
-func (r repoInfo) getRepoPath() string {
+func (r repoInformation) getRepoPath() string {
 	return fmt.Sprintf("%s/%s", r.owner, r.repo)
 }
 
-func (r repoInfo) isInvalid() bool {
+func (r repoInformation) isInvalid() bool {
 	return r.provider == "" || r.owner == "" || r.repo == "" || r.tokenId == ""
 }
 
-func getRepoInfo(repo *v1alpha3.MultiBranchPipeline) (info repoInfo) {
+func getRepoInfo(repo *v1alpha3.MultiBranchPipeline) (info repoInformation) {
 	if repo == nil {
 		return
 	}
@@ -184,16 +231,11 @@ func (r *PullRequestStatusReconciler) getTokenFromSecret(secretRef *v1.SecretRef
 
 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{
+	ns := stringutils.SetOrDefault(ref.Namespace, defaultNamespace)
+	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)
-	}
+	}, secret)
+	err = stringutils.ErrorOverride(err, "cannot get secret %v, error is: %v", secret, err)
 	return
 }
 
@@ -232,6 +274,9 @@ type StatusMaker struct {
 	token    string
 	username string
 	target   string
+
+	// expirationCheck checks if the current status is expiration that compared to the previous one
+	expirationCheck expirationCheckFunc
 }
 
 // NewStatusMaker creates an instance of statusMaker
@@ -239,9 +284,20 @@ func NewStatusMaker(repo, token string) *StatusMaker {
 	return &StatusMaker{
 		repo:  repo,
 		token: token,
+		expirationCheck: func(previousStatus *scm.Status, currentStatus *scm.StatusInput) bool {
+			return false
+		},
 	}
 }
 
+type expirationCheckFunc func(previousStatus *scm.Status, currentStatus *scm.StatusInput) bool
+
+// WithExpirationCheck set the expiration check function
+func (s *StatusMaker) WithExpirationCheck(check expirationCheckFunc) *StatusMaker {
+	s.expirationCheck = check
+	return s
+}
+
 // WithUsername sets the username
 func (s *StatusMaker) WithUsername(username string) *StatusMaker {
 	s.username = username
@@ -254,7 +310,7 @@ func (s *StatusMaker) WithProvider(provider string) *StatusMaker {
 	return s
 }
 
-// WithProvider sets the server
+// WithServer sets the server
 func (s *StatusMaker) WithServer(server string) *StatusMaker {
 	s.server = server
 	return s
@@ -274,8 +330,8 @@ func (s *StatusMaker) WithPR(pr int) *StatusMaker {
 
 // 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) {
+	var scmClient *scm.Client
+	scmClient, err = factory.NewClient(s.provider, s.server, s.token, func(c *scm.Client) {
 		c.Username = s.username
 	})
 	if err != nil {
@@ -283,13 +339,42 @@ func (s *StatusMaker) Create(ctx context.Context, status scm.State, label, desc
 	}
 
 	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{
+	if pullRequest, _, err = scmClient.PullRequests.Find(ctx, s.repo, s.pr); err == nil {
+		var previousStatus *scm.Status
+		if previousStatus, err = s.FindPreviousStatus(ctx, scmClient, pullRequest.Sha, label); err != nil {
+			return
+		}
+
+		currentStatus := &scm.StatusInput{
 			Desc:   desc,
 			Label:  label,
 			State:  status,
 			Target: s.target,
-		})
+		}
+		// avoid the previous building status override newer one
+		if !s.expirationCheck(previousStatus, currentStatus) {
+			_, _, err = scmClient.Repositories.CreateStatus(ctx, s.repo, pullRequest.Sha, currentStatus)
+		}
+	}
+	return
+}
+
+// FindPreviousStatus finds the existing status by sha and label
+func (s *StatusMaker) FindPreviousStatus(ctx context.Context, scmClient *scm.Client, sha, label string) (target *scm.Status, err error) {
+	var exists []*scm.Status
+	if exists, _, err = scmClient.Repositories.ListStatus(ctx, s.repo, sha, &scm.ListOptions{
+		Page: 1,
+		Size: 100, // assume this list has not too many items
+	}); err != nil {
+		err = fmt.Errorf("failed to list the existing status, error: %v", err)
+		return
+	}
+
+	for _, item := range exists {
+		if item.Label == label {
+			target = item
+			break
+		}
 	}
 	return
 }
@@ -314,5 +399,5 @@ func convertPipelineRunPhaseToSCMStatus(phase v1alpha3.RunPhase) (status scm.Sta
 	default:
 		status = scm.StateUnknown
 	}
-	return status
+	return
 }
diff --git a/pkg/utils/stringutils/error.go b/pkg/utils/stringutils/error.go
new file mode 100644
index 0000000..64c3948
--- /dev/null
+++ b/pkg/utils/stringutils/error.go
@@ -0,0 +1,27 @@
+/*
+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 stringutils
+
+import "fmt"
+
+// ErrorOverride appends a message if the error is not nil
+func ErrorOverride(err error, format string, a ...interface{}) error {
+	if err != nil {
+		err = fmt.Errorf(format, a...)
+	}
+	return err
+}


Test patch:
diff --git a/controllers/gitrepository/pull_request_status_controller_test.go b/controllers/gitrepository/pull_request_status_controller_test.go
index 56b80e2..361ca2e 100644
--- a/controllers/gitrepository/pull_request_status_controller_test.go
+++ b/controllers/gitrepository/pull_request_status_controller_test.go
@@ -16,7 +16,9 @@ package gitrepository
 import (
 	"context"
 	"fmt"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"testing"
+	"time"
 
 	"github.com/go-logr/logr"
 	"github.com/h2non/gock"
@@ -40,32 +42,107 @@ var mockHeaders = map[string]string{
 	"X-RateLimit-Reset":     "1512076018",
 }
 
+var mockPageHeaders = map[string]string{
+	"Link": `<https://api.github.com/resource?page=2>; rel="next",` +
+		`<https://api.github.com/resource?page=1>; rel="prev",` +
+		`<https://api.github.com/resource?page=1>; rel="first",` +
+		`<https://api.github.com/resource?page=5>; rel="last"`,
+}
+
 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)
+	tests := []struct {
+		name              string
+		createStatusMaker func() *StatusMaker
+		wantErr           bool
+	}{{
+		name: "normal case",
+		createStatusMaker: func() *StatusMaker {
+			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")
+
+			gock.New("https://api.github.com").
+				Get("/repos/octocat/hello-world/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e").
+				MatchParam("page", "1").
+				MatchParam("per_page", "100").
+				Reply(200).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				SetHeaders(mockPageHeaders).
+				File("testdata/statuses.json")
+
+			maker := NewStatusMaker("octocat/hello-world", "")
+			maker.WithTarget("https://ci.example.com/1000/output").WithPR(1347)
+			return maker
+		},
+		wantErr: false,
+	}, {
+		name: "failed to request the status list API",
+		createStatusMaker: func() *StatusMaker {
+			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)
+			return maker
+		},
+		wantErr: true,
+	}, {
+		name: "invalid go scm provider",
+		createStatusMaker: func() *StatusMaker {
+			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.WithProvider("fake-provider").WithServer("fake-server")
-	err = maker.Create(context.Background(), scm.StateSuccess, "continuous-integration/drone", "Build has completed successfully")
-	assert.NotNil(t, err)
+			maker := NewStatusMaker("octocat/hello-world", "")
+			maker.WithProvider("fake-provider").WithServer("fake-server").WithPR(1347)
+			return maker
+		},
+		wantErr: true,
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			defer gock.Off()
+			statusMaker := tt.createStatusMaker()
+			err := statusMaker.Create(context.Background(), scm.StateSuccess, "continuous-integration/drone", "Build has completed successfully")
+			if tt.wantErr {
+				assert.NotNil(t, err, "should have error in case [%d]", i)
+			} else {
+				assert.Nil(t, err, "should not have error in case [%d]", i)
+			}
+		})
+	}
 }
 
 func TestPullRequestStatusReconciler_SetupWithManager(t *testing.T) {
@@ -185,6 +262,16 @@ func TestPullRequestStatusReconciler(t *testing.T) {
 				Type("application/json").
 				SetHeaders(mockHeaders).
 				File("testdata/status.json")
+
+			gock.New("https://api.github.com").
+				Get("/repos/octocat/hello-world/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e").
+				MatchParam("page", "1").
+				MatchParam("per_page", "100").
+				Reply(200).
+				Type("application/json").
+				SetHeaders(mockHeaders).
+				SetHeaders(mockPageHeaders).
+				File("testdata/statuses.json")
 		},
 		k8sClient: fake.NewClientBuilder().WithScheme(schema).WithRuntimeObjects(pipRun.DeepCopy(), secret.DeepCopy(), project.DeepCopy()).Build(),
 		wantErr:   false,
@@ -297,12 +384,12 @@ func TestConvertPipelineRunPhaseToSCMStatus(t *testing.T) {
 }
 
 func TestGetRepoInfo(t *testing.T) {
-	emptyRepoInfo := repoInfo{}
+	emptyRepoInfo := repoInformation{}
 
 	tests := []struct {
 		name     string
 		repo     *v1alpha3.MultiBranchPipeline
-		wantInfo repoInfo
+		wantInfo repoInformation
 	}{{
 		name:     "repo is nil",
 		repo:     nil,
@@ -317,7 +404,7 @@ func TestGetRepoInfo(t *testing.T) {
 				CredentialId: "token",
 			},
 		},
-		wantInfo: repoInfo{owner: "owner", repo: "repo", tokenId: "token", provider: "github"},
+		wantInfo: repoInformation{owner: "owner", repo: "repo", tokenId: "token", provider: "github"},
 	}, {
 		name: "gitlab",
 		repo: &v1alpha3.MultiBranchPipeline{
@@ -328,7 +415,7 @@ func TestGetRepoInfo(t *testing.T) {
 				CredentialId: "token",
 			},
 		},
-		wantInfo: repoInfo{owner: "owner", repo: "repo", tokenId: "token", provider: "gitlab"},
+		wantInfo: repoInformation{owner: "owner", repo: "repo", tokenId: "token", provider: "gitlab"},
 	}, {
 		name: "bitbucket",
 		repo: &v1alpha3.MultiBranchPipeline{
@@ -340,7 +427,7 @@ func TestGetRepoInfo(t *testing.T) {
 				CredentialId: "token",
 			},
 		},
-		wantInfo: repoInfo{owner: "owner", repo: "repo", tokenId: "token", provider: "bitbucketcloud"},
+		wantInfo: repoInformation{owner: "owner", repo: "repo", tokenId: "token", provider: "bitbucketcloud"},
 	}}
 	for i, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -349,3 +436,93 @@ func TestGetRepoInfo(t *testing.T) {
 		})
 	}
 }
+
+func Test_getPipelineRunNameAndNsFromURL(t *testing.T) {
+	type args struct {
+		link string
+	}
+	tests := []struct {
+		name     string
+		args     args
+		wantName string
+		wantNs   string
+		wantErr  assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		args: args{
+			link: "http://ip:port/ks-devops-core/clusters/host/devops/core58fgv/pipelines/ks-devops/branch/PR-816/run/ks-devops-pzdcz/task-status",
+		},
+		wantName: "ks-devops-pzdcz",
+		wantNs:   "core58fgv",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotName, gotNs, err := getPipelineRunNameAndNsFromURL(tt.args.link)
+			if !tt.wantErr(t, err, fmt.Sprintf("getPipelineRunNameAndNsFromURL(%v)", tt.args.link)) {
+				return
+			}
+			assert.Equalf(t, tt.wantName, gotName, "getPipelineRunNameAndNsFromURL(%v)", tt.args.link)
+			assert.Equalf(t, tt.wantNs, gotNs, "getPipelineRunNameAndNsFromURL(%v)", tt.args.link)
+		})
+	}
+}
+
+func TestCreateExpirationCheckFunc(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	previousStatus := &scm.Status{Target: "http://ip:port/ks-devops-core/clusters/host/devops/core58fgv/pipelines/ks-devops/branch/PR-816/run/ks-devops-pzdcz/task-status"}
+
+	previousPipelineRun := &v1alpha3.PipelineRun{
+		Status: v1alpha3.PipelineRunStatus{
+			StartTime: &metav1.Time{Time: time.Now()},
+		},
+	}
+	previousPipelineRun.SetName("ks-devops-pzdcz")
+	previousPipelineRun.SetNamespace("core58fgv")
+
+	currentPipelineRun := &v1alpha3.PipelineRun{
+		Status: v1alpha3.PipelineRunStatus{
+			StartTime: &metav1.Time{Time: time.Now().Add(time.Minute)},
+		},
+	}
+
+	tests := []struct {
+		name               string
+		k8sClient          client.Client
+		currentPipelineRun *v1alpha3.PipelineRun
+		previousStatus     *scm.Status
+		currentStatus      *scm.StatusInput
+		wantBool           bool
+	}{{
+		name:               "cannot find previous PipelineRun",
+		k8sClient:          fake.NewClientBuilder().WithScheme(schema).Build(),
+		currentPipelineRun: currentPipelineRun.DeepCopy(),
+		previousStatus:     previousStatus,
+		wantBool:           false,
+	}, {
+		name:               "the current PipelineRun is newer",
+		k8sClient:          fake.NewClientBuilder().WithScheme(schema).WithObjects(previousPipelineRun.DeepCopy()).Build(),
+		previousStatus:     previousStatus,
+		currentPipelineRun: currentPipelineRun.DeepCopy(),
+		wantBool:           false,
+	}, {
+		name:               "previous and the current one belong to the same PipelineRun",
+		k8sClient:          fake.NewClientBuilder().WithScheme(schema).WithObjects(previousPipelineRun.DeepCopy()).Build(),
+		previousStatus:     previousStatus,
+		currentPipelineRun: previousPipelineRun.DeepCopy(),
+		wantBool:           false,
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			checkFunc := createExpirationCheckFunc(context.TODO(), tt.k8sClient, tt.currentPipelineRun)
+			result := checkFunc(tt.previousStatus, tt.currentStatus)
+			assert.Equal(t, tt.wantBool, result, "failed in case [%d]", i)
+		})
+	}
+}
diff --git a/controllers/gitrepository/testdata/statuses.json b/controllers/gitrepository/testdata/statuses.json
new file mode 100644
index 0000000..7b1424d
--- /dev/null
+++ b/controllers/gitrepository/testdata/statuses.json
@@ -0,0 +1,60 @@
+[
+    {
+        "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
+        }
+    },
+    {
+        "created_at": "2012-06-20T01:19:13Z",
+        "updated_at": "2012-06-20T01:19:13Z",
+        "state": "failure",
+        "target_url": "https://ci.example.com/1000/output",
+        "description": "Build failed",
+        "id": 2,
+        "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/utils/stringutils/error_test.go b/pkg/utils/stringutils/error_test.go
new file mode 100644
index 0000000..93eac56
--- /dev/null
+++ b/pkg/utils/stringutils/error_test.go
@@ -0,0 +1,55 @@
+/*
+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 stringutils
+
+import (
+	"kubesphere.io/devops/pkg/server/errors"
+	"testing"
+)
+
+func TestErrorOverride(t *testing.T) {
+	type args struct {
+		err    error
+		format string
+		a      []interface{}
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{{
+		name: "error is nil",
+		args: args{
+			err: nil,
+		},
+		wantErr: false,
+	}, {
+		name: "error is not nil",
+		args: args{
+			err:    errors.New("an error"),
+			format: "msg",
+		},
+		wantErr: true,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := ErrorOverride(tt.args.err, tt.args.format, tt.args.a...); (err != nil) != tt.wantErr {
+				t.Errorf("ErrorOverride() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}