Skip to content

Commit 5de31a6

Browse files
committed
feat: auto-cancel PipelineRuns on PR close
The pipelinesascode.tekton.dev/cancel-in-progress: "true" feature annotation has now been enhanced to include automatic cancellation of PipelineRuns when the associated pull request is closed or merged. Jira: https://issues.redhat.com/browse/SRVKP-6908 Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
1 parent 5305656 commit 5de31a6

28 files changed

+422
-86
lines changed

pkg/formatting/pipelinerun.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ package formatting
33
import (
44
tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
55
corev1 "k8s.io/api/core/v1"
6+
"knative.dev/pkg/apis"
67
)
78

89
// PipelineRunStatus return status of PR success failed or skipped.
910
func PipelineRunStatus(pr *tektonv1.PipelineRun) string {
1011
if len(pr.Status.Conditions) == 0 {
1112
return "neutral"
1213
}
14+
if pr.Status.GetCondition(apis.ConditionSucceeded).GetReason() == tektonv1.PipelineRunSpecStatusCancelled {
15+
return "cancelled"
16+
}
1317
if pr.Status.Conditions[0].Status == corev1.ConditionFalse {
1418
return "failure"
1519
}

pkg/formatting/pipelinerun_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
77
"gotest.tools/v3/assert"
88
corev1 "k8s.io/api/core/v1"
9+
"knative.dev/pkg/apis"
910
knativeduckv1 "knative.dev/pkg/apis/duck/v1"
1011
)
1112

@@ -30,6 +31,23 @@ func TestPipelineRunStatus(t *testing.T) {
3031
},
3132
},
3233
},
34+
{
35+
name: "cancelled",
36+
pr: &tektonv1.PipelineRun{
37+
Status: tektonv1.PipelineRunStatus{
38+
Status: knativeduckv1.Status{
39+
Conditions: knativeduckv1.Conditions{
40+
{
41+
Status: corev1.ConditionTrue,
42+
Reason: tektonv1.PipelineRunSpecStatusCancelled,
43+
Message: "Cancelled",
44+
Type: apis.ConditionSucceeded,
45+
},
46+
},
47+
},
48+
},
49+
},
50+
},
3351
{
3452
name: "failure",
3553
pr: &tektonv1.PipelineRun{

pkg/params/triggertype/types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ const (
3636
OkToTest Trigger = "ok-to-test"
3737
Retest Trigger = "retest"
3838
Push Trigger = "push"
39-
PullRequest Trigger = "pull_request"
39+
PullRequest Trigger = "pull_request" // it's should be "pull_request_opened_updated" but let's keep it simple.
40+
PullRequestClosed Trigger = "pull_request_closed"
4041
Cancel Trigger = "cancel"
4142
CheckSuiteRerequested Trigger = "check-suite-rerequested"
4243
CheckRunRerequested Trigger = "check-run-rerequested"

pkg/pipelineascode/cancel_pipelineruns.go

Lines changed: 63 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,47 @@ import (
2020
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/triggertype"
2121
)
2222

23+
type matchingCond func(pr tektonv1.PipelineRun) bool
24+
2325
var cancelMergePatch = map[string]interface{}{
2426
"spec": map[string]interface{}{
2527
"status": tektonv1.PipelineRunSpecStatusCancelledRunFinally,
2628
},
2729
}
2830

29-
// cancelInProgress cancels all PipelineRuns associated with a given repository and pull request,
31+
func (p *PacRun) cancelAllInProgressBelongingToPullRequest(ctx context.Context, repo *v1alpha1.Repository) error {
32+
labelSelector := getLabelSelector(map[string]string{
33+
keys.URLRepository: formatting.CleanValueKubernetes(p.event.Repository),
34+
keys.PullRequest: strconv.Itoa(int(p.event.PullRequestNumber)),
35+
})
36+
prs, err := p.run.Clients.Tekton.TektonV1().PipelineRuns(repo.Namespace).List(ctx, metav1.ListOptions{
37+
LabelSelector: labelSelector,
38+
})
39+
if err != nil {
40+
return fmt.Errorf("failed to list pipelineRuns : %w", err)
41+
}
42+
43+
if len(prs.Items) == 0 {
44+
msg := fmt.Sprintf("no pipelinerun found for repository: %v and pullRequest %v",
45+
p.event.Repository, p.event.PullRequestNumber)
46+
p.eventEmitter.EmitMessage(repo, zap.InfoLevel, "RepositoryPipelineRun", msg)
47+
return nil
48+
}
49+
50+
p.cancelPipelineRuns(ctx, prs, repo, func(_ tektonv1.PipelineRun) bool {
51+
return true
52+
})
53+
54+
return nil
55+
}
56+
57+
// cancelInProgressMatchingPR cancels all PipelineRuns associated with a given repository and pull request,
3058
// except for the one that triggered the cancellation. It first checks if the cancellation is in progress
3159
// and if the repository has a concurrency limit. If a concurrency limit is set, it returns an error as
3260
// cancellation is not supported with concurrency limits. It then retrieves the original pull request name
3361
// from the annotations and lists all PipelineRuns with matching labels. For each PipelineRun that is not
3462
// already done, cancelled, or gracefully stopped, it patches the PipelineRun to cancel it.
35-
func (p *PacRun) cancelInProgress(ctx context.Context, matchPR *tektonv1.PipelineRun, repo *v1alpha1.Repository) error {
63+
func (p *PacRun) cancelInProgressMatchingPR(ctx context.Context, matchPR *tektonv1.PipelineRun, repo *v1alpha1.Repository) error {
3664
if matchPR == nil {
3765
return nil
3866
}
@@ -67,51 +95,28 @@ func (p *PacRun) cancelInProgress(ctx context.Context, matchPR *tektonv1.Pipelin
6795
if err != nil {
6896
return fmt.Errorf("failed to list pipelineRuns : %w", err)
6997
}
70-
var wg sync.WaitGroup
71-
for _, pr := range prs.Items {
72-
if pr.GetName() == matchPR.GetName() {
73-
continue
74-
}
98+
99+
p.cancelPipelineRuns(ctx, prs, repo, func(pr tektonv1.PipelineRun) bool {
100+
// skip our own for cancellation
75101
if sourceBranch, ok := pr.GetAnnotations()[keys.SourceBranch]; ok {
76102
// NOTE(chmouel): Every PR has their own branch and so is every push to different branch
77103
// it means we only cancel pipelinerun of the same name that runs to
78104
// the unique branch. Note: HeadBranch is the branch from where the PR
79105
// comes from in git jargon.
80106
if sourceBranch != p.event.HeadBranch {
81107
p.logger.Infof("cancel-in-progress: skipping pipelinerun %v/%v as it is not from the same branch, annotation source-branch: %s event headbranch: %s", pr.GetNamespace(), pr.GetName(), sourceBranch, p.event.HeadBranch)
82-
continue
108+
return false
83109
}
84110
}
85111

86-
if pr.IsPending() {
87-
p.logger.Infof("cancel-in-progress: skipping pipelinerun %v/%v as it is pending", pr.GetNamespace(), pr.GetName())
88-
}
89-
90-
if pr.IsDone() {
91-
p.logger.Infof("cancel-in-progress: skipping pipelinerun %v/%v as it is done", pr.GetNamespace(), pr.GetName())
92-
continue
93-
}
94-
if pr.IsCancelled() || pr.IsGracefullyCancelled() || pr.IsGracefullyStopped() {
95-
p.logger.Infof("cancel-in-progress: skipping pipelinerun %v/%v as it is already in %v state", pr.GetNamespace(), pr.GetName(), pr.Spec.Status)
96-
continue
97-
}
98-
99-
p.logger.Infof("cancel-in-progress: cancelling pipelinerun %v/%v", pr.GetNamespace(), pr.GetName())
100-
wg.Add(1)
101-
go func(ctx context.Context, pr tektonv1.PipelineRun) {
102-
defer wg.Done()
103-
if _, err := action.PatchPipelineRun(ctx, p.logger, "cancel patch", p.run.Clients.Tekton, &pr, cancelMergePatch); err != nil {
104-
errMsg := fmt.Sprintf("failed to cancel pipelineRun %s/%s: %s", pr.GetNamespace(), pr.GetName(), err.Error())
105-
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "RepositoryPipelineRun", errMsg)
106-
}
107-
}(ctx, pr)
108-
}
109-
wg.Wait()
110-
112+
return pr.GetName() != matchPR.GetName()
113+
})
111114
return nil
112115
}
113116

114-
func (p *PacRun) cancelPipelineRuns(ctx context.Context, repo *v1alpha1.Repository) error {
117+
// cancelPipelineRunsOpsComment cancels all PipelineRuns associated with a given repository and pull request.
118+
// when the user issue a cancel comment.
119+
func (p *PacRun) cancelPipelineRunsOpsComment(ctx context.Context, repo *v1alpha1.Repository) error {
115120
labelSelector := getLabelSelector(map[string]string{
116121
keys.URLRepository: formatting.CleanValueKubernetes(p.event.Repository),
117122
keys.SHA: formatting.CleanValueKubernetes(p.event.SHA),
@@ -137,22 +142,40 @@ func (p *PacRun) cancelPipelineRuns(ctx context.Context, repo *v1alpha1.Reposito
137142
return nil
138143
}
139144

140-
var wg sync.WaitGroup
141-
for _, pr := range prs.Items {
145+
p.cancelPipelineRuns(ctx, prs, repo, func(pr tektonv1.PipelineRun) bool {
142146
if p.event.TargetCancelPipelineRun != "" {
143147
if prName, ok := pr.GetAnnotations()[keys.OriginalPRName]; !ok || prName != p.event.TargetCancelPipelineRun {
144-
continue
148+
return false
145149
}
146150
}
147-
if pr.IsDone() {
148-
p.logger.Infof("pipelinerun %v/%v is done, skipping cancellation", pr.GetNamespace(), pr.GetName())
151+
return true
152+
})
153+
154+
return nil
155+
}
156+
157+
func (p *PacRun) cancelPipelineRuns(ctx context.Context, prs *tektonv1.PipelineRunList, repo *v1alpha1.Repository, condition matchingCond) {
158+
var wg sync.WaitGroup
159+
for _, pr := range prs.Items {
160+
if !condition(pr) {
149161
continue
150162
}
163+
151164
if pr.IsCancelled() || pr.IsGracefullyCancelled() || pr.IsGracefullyStopped() {
152-
p.logger.Infof("pipelinerun %v/%v is already in %v state", pr.GetNamespace(), pr.GetName(), pr.Spec.Status)
165+
p.logger.Infof("cancel-in-progress: skipping cancelling pipelinerun %v/%v, already in %v state", pr.GetNamespace(), pr.GetName(), pr.Spec.Status)
153166
continue
154167
}
155168

169+
if pr.IsDone() {
170+
p.logger.Infof("cancel-in-progress: skipping cancelling pipelinerun %v/%v, already done", pr.GetNamespace(), pr.GetName())
171+
continue
172+
}
173+
174+
if pr.IsPending() {
175+
p.logger.Infof("cancel-in-progress: skipping cancelling pipelinerun %v/%v in pending state", pr.GetNamespace(), pr.GetName())
176+
}
177+
178+
p.logger.Infof("cancel-in-progress: cancelling pipelinerun %v/%v", pr.GetNamespace(), pr.GetName())
156179
wg.Add(1)
157180
go func(ctx context.Context, pr tektonv1.PipelineRun) {
158181
defer wg.Done()
@@ -163,8 +186,6 @@ func (p *PacRun) cancelPipelineRuns(ctx context.Context, repo *v1alpha1.Reposito
163186
}(ctx, pr)
164187
}
165188
wg.Wait()
166-
167-
return nil
168189
}
169190

170191
func getLabelSelector(labelsMap map[string]string) string {

pkg/pipelineascode/cancel_pipelineruns_test.go

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ var (
7171
}
7272
)
7373

74-
func TestCancelPipelinerun(t *testing.T) {
74+
func TestCancelPipelinerunOpsComment(t *testing.T) {
7575
observer, _ := zapobserver.New(zap.InfoLevel)
7676
logger := zap.New(observer).Sugar()
7777
tests := []struct {
@@ -300,7 +300,7 @@ func TestCancelPipelinerun(t *testing.T) {
300300
},
301301
}
302302
pac := NewPacs(tt.event, nil, cs, &info.PacOpts{}, nil, logger, nil)
303-
err := pac.cancelPipelineRuns(ctx, tt.repo)
303+
err := pac.cancelPipelineRunsOpsComment(ctx, tt.repo)
304304
assert.NilError(t, err)
305305

306306
got, err := cs.Clients.Tekton.TektonV1().PipelineRuns("foo").List(ctx, metav1.ListOptions{})
@@ -318,7 +318,7 @@ func TestCancelPipelinerun(t *testing.T) {
318318
}
319319
}
320320

321-
func TestCancelInProgress(t *testing.T) {
321+
func TestCancelInProgressMatchingPR(t *testing.T) {
322322
observer, catcher := zapobserver.New(zap.InfoLevel)
323323
logger := zap.New(observer).Sugar()
324324
tests := []struct {
@@ -789,7 +789,7 @@ func TestCancelInProgress(t *testing.T) {
789789
if len(tt.pipelineRuns) > 0 {
790790
firstPr = tt.pipelineRuns[0]
791791
}
792-
err := pac.cancelInProgress(ctx, firstPr, tt.repo)
792+
err := pac.cancelInProgressMatchingPR(ctx, firstPr, tt.repo)
793793
if tt.wantErrString != "" {
794794
assert.ErrorContains(t, err, tt.wantErrString)
795795
return
@@ -818,6 +818,94 @@ func TestCancelInProgress(t *testing.T) {
818818
}
819819
}
820820

821+
func TestCancelAllInProgressBelongingToPullRequest(t *testing.T) {
822+
observer, _ := zapobserver.New(zap.InfoLevel)
823+
logger := zap.New(observer).Sugar()
824+
825+
tests := []struct {
826+
name string
827+
event *info.Event
828+
repo *v1alpha1.Repository
829+
pipelineRuns []*pipelinev1.PipelineRun
830+
cancelledPipelineRuns map[string]bool
831+
}{
832+
{
833+
name: "cancel all in progress PipelineRuns",
834+
event: &info.Event{
835+
Repository: "foo",
836+
TriggerTarget: "pull_request",
837+
PullRequestNumber: pullReqNumber,
838+
},
839+
repo: fooRepo,
840+
pipelineRuns: []*pipelinev1.PipelineRun{
841+
{
842+
ObjectMeta: metav1.ObjectMeta{
843+
Name: "pr-foo-1",
844+
Namespace: "foo",
845+
Labels: fooRepoLabels,
846+
},
847+
Spec: pipelinev1.PipelineRunSpec{},
848+
},
849+
{
850+
ObjectMeta: metav1.ObjectMeta{
851+
Name: "pr-foo-2",
852+
Namespace: "foo",
853+
Labels: fooRepoLabels,
854+
},
855+
Spec: pipelinev1.PipelineRunSpec{},
856+
},
857+
},
858+
cancelledPipelineRuns: map[string]bool{
859+
"pr-foo-1": true,
860+
"pr-foo-2": true,
861+
},
862+
},
863+
{
864+
name: "no PipelineRuns to cancel",
865+
event: &info.Event{
866+
Repository: "foo",
867+
TriggerTarget: "pull_request",
868+
PullRequestNumber: pullReqNumber,
869+
},
870+
repo: fooRepo,
871+
pipelineRuns: []*pipelinev1.PipelineRun{},
872+
cancelledPipelineRuns: map[string]bool{},
873+
},
874+
}
875+
876+
for _, tt := range tests {
877+
t.Run(tt.name, func(t *testing.T) {
878+
ctx, _ := rtesting.SetupFakeContext(t)
879+
880+
tdata := testclient.Data{
881+
PipelineRuns: tt.pipelineRuns,
882+
}
883+
stdata, _ := testclient.SeedTestData(t, ctx, tdata)
884+
cs := &params.Run{
885+
Clients: clients.Clients{
886+
Log: logger,
887+
Tekton: stdata.Pipeline,
888+
Kube: stdata.Kube,
889+
},
890+
}
891+
pac := NewPacs(tt.event, nil, cs, &info.PacOpts{}, nil, logger, nil)
892+
err := pac.cancelAllInProgressBelongingToPullRequest(ctx, tt.repo)
893+
assert.NilError(t, err)
894+
895+
got, err := cs.Clients.Tekton.TektonV1().PipelineRuns("foo").List(ctx, metav1.ListOptions{})
896+
assert.NilError(t, err)
897+
898+
for _, pr := range got.Items {
899+
if _, ok := tt.cancelledPipelineRuns[pr.Name]; ok {
900+
assert.Equal(t, string(pr.Spec.Status), pipelinev1.PipelineRunSpecStatusCancelledRunFinally)
901+
} else {
902+
assert.Assert(t, string(pr.Spec.Status) != pipelinev1.PipelineRunSpecStatusCancelledRunFinally)
903+
}
904+
}
905+
})
906+
}
907+
}
908+
821909
func TestGetLabelSelector(t *testing.T) {
822910
tests := []struct {
823911
name string

pkg/pipelineascode/match.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func (p *PacRun) matchRepoPR(ctx context.Context) ([]matcher.Match, *v1alpha1.Re
3030
}
3131

3232
if p.event.CancelPipelineRuns {
33-
return nil, repo, p.cancelPipelineRuns(ctx, repo)
33+
return nil, repo, p.cancelPipelineRunsOpsComment(ctx, repo)
3434
}
3535

3636
matchedPRs, err := p.getPipelineRunsFromRepo(ctx, repo)

pkg/pipelineascode/pipelineascode.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/clients"
1818
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/info"
1919
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/settings"
20+
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/triggertype"
2021
"github.com/openshift-pipelines/pipelines-as-code/pkg/provider"
2122
"github.com/openshift-pipelines/pipelines-as-code/pkg/secrets"
2223
tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
@@ -55,6 +56,12 @@ func NewPacs(event *info.Event, vcx provider.Interface, run *params.Run, pacInfo
5556

5657
func (p *PacRun) Run(ctx context.Context) error {
5758
matchedPRs, repo, err := p.matchRepoPR(ctx)
59+
if repo != nil && p.event.TriggerTarget == triggertype.PullRequestClosed {
60+
if err := p.cancelAllInProgressBelongingToPullRequest(ctx, repo); err != nil {
61+
return fmt.Errorf("error cancelling in progress pipelineRuns belonging to pull request %d: %w", p.event.PullRequestNumber, err)
62+
}
63+
return nil
64+
}
5865
if err != nil {
5966
createStatusErr := p.vcx.CreateStatus(ctx, p.event, provider.StatusOpts{
6067
Status: CompletedStatus,
@@ -116,7 +123,7 @@ func (p *PacRun) Run(ctx context.Context) error {
116123
}
117124
}
118125
p.manager.AddPipelineRun(pr)
119-
if err := p.cancelInProgress(ctx, pr, repo); err != nil {
126+
if err := p.cancelInProgressMatchingPR(ctx, pr, repo); err != nil {
120127
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "RepositoryPipelineRun", fmt.Sprintf("error cancelling in progress pipelineRuns: %s", err))
121128
}
122129
}(match, i)

0 commit comments

Comments
 (0)