Skip to content

Commit cd437c5

Browse files
waveywavespipelines-as-code[bot]
authored andcommitted
fix: don't restart successful pipelineruns on /retest /ok-to-test
/retest and /ok-to-test should only restart failed pipelines; the changes in the PR ensure that we do not match successful pipelineruns during invocation of these gitops commands. The tests have also been updated to use /test instead wherever /retest is being used to restart successful pipelines Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com> Assisted-by: Claude-4-Sonnet (via Cursor)
1 parent 79c8e77 commit cd437c5

14 files changed

+328
-37
lines changed

docs/content/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ tracking using a Git workflow.
3535

3636
<--->
3737

38-
- Pull-request "*GitOps*" actions through comments with `/retest`, `/test <pipeline-name>` and so on.
38+
- Pull-request "*GitOps*" actions through comments with `/retest` (reruns failed pipelines), `/test <pipeline-name>` (force rerun specific pipeline) and so on.
3939

4040
- Automatic Task resolution in Pipelines (local Tasks, Artifact Hub, and remote URLs)
4141

docs/content/docs/guide/gitops_commands.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The advantage of using a `GitOps command` is that it provides a journal of all t
1111

1212
## GitOps Commands on Pull Requests
1313

14-
For example, when you are on a Pull Request, you may want to restart all your PipelineRuns. To do so, you can add a comment on your Pull Request starting with `/retest`, and all PipelineRuns attached to that Pull Request will be restarted.
14+
For example, when you are on a Pull Request, you may want to restart failed PipelineRuns. To do so, you can add a comment on your Pull Request starting with `/retest`, and all **failed** PipelineRuns attached to that Pull Request will be restarted. If all previous PipelineRuns for the same commit were successful, no new PipelineRuns will be created to avoid unnecessary duplication.
1515

1616
Example:
1717

@@ -22,6 +22,23 @@ failure is not with your PR but seems to be an infrastructure issue.
2222
/retest
2323
```
2424

25+
The `/retest` command will only create new PipelineRuns if:
26+
27+
- Previously **failed** PipelineRuns for the same commit, OR
28+
- No PipelineRun has been run for the same commit yet
29+
30+
If a successful PipelineRun already exists for the same commit, `/retest` will **skip** triggering a new PipelineRun to avoid unnecessary duplication.
31+
32+
**To force a rerun regardless of previous status**, use:
33+
34+
```text
35+
/retest <pipelinerun-name>
36+
```
37+
38+
This will always trigger a new PipelineRun, even if previous runs were successful.
39+
40+
Similar to `/retest`, the `/ok-to-test` command will only trigger new PipelineRuns if no successful PipelineRun already exists for the same commit. This prevents duplicate runs when repository owners repeatedly test the same commit by `/test` and `/retest` command.
41+
2542
If you have multiple `PipelineRun` and you want to target a specific `PipelineRun`, you can use the `/test` command followed by the specific PipelineRun name to restart it. Example:
2643

2744
```text
@@ -335,12 +352,12 @@ Here are the possible event types:
335352

336353
- `test-all-comment`: The event is a single `/test` that would test every matched PipelineRun.
337354
- `test-comment`: The event is a `/test <PipelineRun>` comment that would test a specific PipelineRun.
338-
- `retest-all-comment`: The event is a single `/retest` that would retest every matched PipelineRun.
355+
- `retest-all-comment`: The event is a single `/retest` that would retest every matched **failed** PipelineRun. If a successful PipelineRun already exists for the same commit, no new PipelineRun will be created.
339356
- `retest-comment`: The event is a `/retest <PipelineRun>` that would retest a specific PipelineRun.
340357
- `on-comment`: The event is coming from a custom comment that would trigger a PipelineRun.
341358
- `cancel-all-comment`: The event is a single `/cancel` that would cancel every matched PipelineRun.
342359
- `cancel-comment`: The event is a `/cancel <PipelineRun>` that would cancel a specific PipelineRun.
343-
- `ok-to-test-comment`: The event is a `/ok-to-test` that would allow running the CI for an unauthorized user.
360+
- `ok-to-test-comment`: The event is a `/ok-to-test` that would allow running the CI for an unauthorized user. If a successful PipelineRun already exists for the same commit, no new PipelineRun will be created.
344361

345362
If a repository owner comments `/ok-to-test` on a pull request from an external contributor but no PipelineRun **matches** the `pull_request` event (or the repository has no `.tekton` directory), Pipelines-as-Code sets a **neutral** commit status. This indicates that no PipelineRun was matched, allowing other workflows—such as auto-merge—to proceed without being blocked.
346363

docs/content/docs/guide/policy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ or other supported Git providers (currently GitHub and Gitea).
2323
to trigger the CI for a pull request by commenting `/ok-to-test`. This enables
2424
CI to run on pull requests submitted by contributors who are not collaborators
2525
of the repository or organization. It also applies to `/test` and `/retest`
26-
commands. This action takes precedence over the `pull_request` action.
26+
commands. Note that `/retest` will only trigger failed PipelineRuns. This action takes precedence over the `pull_request` action.
2727

2828
## Configuring Policies in the Repository CR
2929

hack/gh-workflow-ci.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ get_tests() {
7575
ghglabre="Github|Gitlab|Bitbucket"
7676
if [[ ${target} == "providers" ]]; then
7777
grep -hioP "^func Test.*(${ghglabre})(\w+)\(" "${testfiles[@]}" | sed -e 's/func[ ]*//' -e 's/($//'
78-
elif [[ ${target} == "gitea_others" ]]; then
78+
elif [[ ${target} == "gitea_others" ]]; then
7979
grep -hioP '^func Test(\w+)\(' "${testfiles[@]}" | grep -iPv "(${ghglabre})" | sed -e 's/func[ ]*//' -e 's/($//'
8080
else
8181
echo "Invalid target: ${target}"

pkg/matcher/annotation_matcher.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
apipac "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
1212
pacerrors "github.com/openshift-pipelines/pipelines-as-code/pkg/errors"
1313
"github.com/openshift-pipelines/pipelines-as-code/pkg/events"
14+
"github.com/openshift-pipelines/pipelines-as-code/pkg/formatting"
1415
"github.com/openshift-pipelines/pipelines-as-code/pkg/opscomments"
1516
"github.com/openshift-pipelines/pipelines-as-code/pkg/params"
1617
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/info"
@@ -21,6 +22,8 @@ import (
2122
"github.com/google/cel-go/common/types"
2223
tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
2324
"go.uber.org/zap"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"knative.dev/pkg/apis"
2427
)
2528

2629
const (
@@ -366,12 +369,77 @@ func MatchPipelinerunByAnnotation(ctx context.Context, logger *zap.SugaredLogger
366369
}
367370

368371
if len(matchedPRs) > 0 {
372+
// Filter out templates that already have successful PipelineRuns for /retest and /ok-to-test
373+
if event.EventType == opscomments.RetestAllCommentEventType.String() ||
374+
event.EventType == opscomments.OkToTestCommentEventType.String() {
375+
return filterSuccessfulTemplates(ctx, logger, cs, event, repo, matchedPRs), nil
376+
}
369377
return matchedPRs, nil
370378
}
371379

372380
return nil, fmt.Errorf("%s", buildAvailableMatchingAnnotationErr(event, pruns))
373381
}
374382

383+
// filterSuccessfulTemplates filters out templates that already have successful PipelineRuns
384+
// when executing /ok-to-test or /retest gitops commands, implementing per-template checking.
385+
func filterSuccessfulTemplates(ctx context.Context, logger *zap.SugaredLogger, cs *params.Run, event *info.Event, repo *apipac.Repository, matchedPRs []Match) []Match {
386+
if event.SHA == "" {
387+
return matchedPRs
388+
}
389+
390+
// Get all existing PipelineRuns for this SHA
391+
labelSelector := fmt.Sprintf("%s=%s", keys.SHA, formatting.CleanValueKubernetes(event.SHA))
392+
existingPRs, err := cs.Clients.Tekton.TektonV1().PipelineRuns(repo.GetNamespace()).List(ctx, metav1.ListOptions{
393+
LabelSelector: labelSelector,
394+
})
395+
if err != nil {
396+
logger.Errorf("failed to list existing PipelineRuns for SHA %s: %v", event.SHA, err)
397+
return matchedPRs // Return all templates if we can't check
398+
}
399+
400+
// Create a map of template names to their most recent successful run
401+
successfulTemplates := make(map[string]*tektonv1.PipelineRun)
402+
403+
for i := range existingPRs.Items {
404+
pr := &existingPRs.Items[i]
405+
406+
// Get the original template name this PipelineRun came from
407+
originalPRName, ok := pr.GetAnnotations()[keys.OriginalPRName]
408+
if !ok {
409+
originalPRName, ok = pr.GetLabels()[keys.OriginalPRName]
410+
}
411+
if !ok {
412+
continue // Skip PipelineRuns without template identification
413+
}
414+
415+
// Check if this PipelineRun succeeded
416+
if pr.Status.GetCondition(apis.ConditionSucceeded).IsTrue() {
417+
// Keep the most recent successful run for each template
418+
if existing, exists := successfulTemplates[originalPRName]; !exists ||
419+
pr.CreationTimestamp.After(existing.CreationTimestamp.Time) {
420+
successfulTemplates[originalPRName] = pr
421+
}
422+
}
423+
}
424+
425+
// Filter out templates that have successful runs
426+
var filteredPRs []Match
427+
428+
for _, match := range matchedPRs {
429+
templateName := getName(match.PipelineRun)
430+
431+
if successfulPR, hasSuccessfulRun := successfulTemplates[templateName]; hasSuccessfulRun {
432+
logger.Infof("skipping template '%s' for sha %s as it already has a successful pipelinerun '%s'",
433+
templateName, event.SHA, successfulPR.Name)
434+
} else {
435+
filteredPRs = append(filteredPRs, match)
436+
}
437+
}
438+
439+
// Return the filtered list (which may be empty if all templates were skipped)
440+
return filteredPRs
441+
}
442+
375443
func buildAvailableMatchingAnnotationErr(event *info.Event, pruns []*tektonv1.PipelineRun) string {
376444
errmsg := "available annotations of the PipelineRuns annotations in .tekton/ dir:"
377445
for _, prun := range pruns {

pkg/matcher/annotation_matcher_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/keys"
1616
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
1717
"github.com/openshift-pipelines/pipelines-as-code/pkg/events"
18+
"github.com/openshift-pipelines/pipelines-as-code/pkg/formatting"
1819
"github.com/openshift-pipelines/pipelines-as-code/pkg/opscomments"
1920
"github.com/openshift-pipelines/pipelines-as-code/pkg/params"
2021
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/clients"
@@ -30,7 +31,10 @@ import (
3031
zapobserver "go.uber.org/zap/zaptest/observer"
3132
"gotest.tools/v3/assert"
3233
"gotest.tools/v3/golden"
34+
corev1 "k8s.io/api/core/v1"
3335
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
36+
"knative.dev/pkg/apis"
37+
knativeduckv1 "knative.dev/pkg/apis/duck/v1"
3438
rtesting "knative.dev/pkg/reconciler/testing"
3539
)
3640

@@ -2599,3 +2603,172 @@ func TestGetName(t *testing.T) {
25992603
})
26002604
}
26012605
}
2606+
2607+
// checkForExistingSuccessfulPipelineRun is a helper function for testing.
2608+
// It checks if there's an existing successful PipelineRun for the same SHA.
2609+
func checkForExistingSuccessfulPipelineRun(ctx context.Context, logger *zap.SugaredLogger, cs *params.Run, event *info.Event, repo *v1alpha1.Repository) *tektonv1.PipelineRun {
2610+
// Only check for /retest and /ok-to-test commands
2611+
if event.EventType != opscomments.RetestAllCommentEventType.String() &&
2612+
event.EventType != opscomments.OkToTestCommentEventType.String() ||
2613+
event.SHA == "" {
2614+
return nil
2615+
}
2616+
2617+
labelSelector := fmt.Sprintf("%s=%s", keys.SHA, formatting.CleanValueKubernetes(event.SHA))
2618+
existingPRs, err := cs.Clients.Tekton.TektonV1().PipelineRuns(repo.GetNamespace()).List(ctx, metav1.ListOptions{
2619+
LabelSelector: labelSelector,
2620+
})
2621+
if err != nil {
2622+
logger.Errorf("failed to list existing PipelineRuns for SHA %s: %v", event.SHA, err)
2623+
return nil
2624+
}
2625+
2626+
// Find the most recent successful PipelineRun
2627+
var mostRecentSuccessfulPR *tektonv1.PipelineRun
2628+
for i := range existingPRs.Items {
2629+
pr := &existingPRs.Items[i]
2630+
if pr.Status.GetCondition(apis.ConditionSucceeded).IsTrue() {
2631+
if mostRecentSuccessfulPR == nil ||
2632+
pr.CreationTimestamp.After(mostRecentSuccessfulPR.CreationTimestamp.Time) {
2633+
mostRecentSuccessfulPR = pr
2634+
}
2635+
}
2636+
}
2637+
2638+
return mostRecentSuccessfulPR
2639+
}
2640+
2641+
func TestCheckForExistingSuccessfulPipelineRun(t *testing.T) {
2642+
ctx, _ := rtesting.SetupFakeContext(t)
2643+
logger := zap.NewExample().Sugar()
2644+
2645+
repo := &v1alpha1.Repository{
2646+
ObjectMeta: metav1.ObjectMeta{
2647+
Name: "test-repo",
2648+
Namespace: "test-ns",
2649+
},
2650+
}
2651+
2652+
// Create a successful PipelineRun
2653+
pr := &tektonv1.PipelineRun{
2654+
ObjectMeta: metav1.ObjectMeta{
2655+
Name: "test-pr",
2656+
Namespace: "test-ns",
2657+
Labels: map[string]string{
2658+
keys.SHA: "test-sha",
2659+
keys.OriginalPRName: "test-pr",
2660+
},
2661+
Annotations: map[string]string{
2662+
keys.OriginalPRName: "test-pr",
2663+
},
2664+
CreationTimestamp: metav1.Now(),
2665+
},
2666+
Status: tektonv1.PipelineRunStatus{
2667+
Status: knativeduckv1.Status{
2668+
Conditions: knativeduckv1.Conditions{
2669+
apis.Condition{
2670+
Type: apis.ConditionSucceeded,
2671+
Status: corev1.ConditionTrue,
2672+
},
2673+
},
2674+
},
2675+
},
2676+
}
2677+
2678+
// Create a failed PipelineRun with the same SHA but older
2679+
earlierTime := metav1.NewTime(time.Now().Add(-1 * time.Hour))
2680+
failedPR := &tektonv1.PipelineRun{
2681+
ObjectMeta: metav1.ObjectMeta{
2682+
Name: "failed-pr",
2683+
Namespace: "test-ns",
2684+
Labels: map[string]string{
2685+
keys.SHA: "test-sha",
2686+
keys.OriginalPRName: "failed-pr",
2687+
},
2688+
Annotations: map[string]string{
2689+
keys.OriginalPRName: "failed-pr",
2690+
},
2691+
CreationTimestamp: earlierTime,
2692+
},
2693+
Status: tektonv1.PipelineRunStatus{
2694+
Status: knativeduckv1.Status{
2695+
Conditions: knativeduckv1.Conditions{
2696+
apis.Condition{
2697+
Type: apis.ConditionSucceeded,
2698+
Status: corev1.ConditionFalse,
2699+
},
2700+
},
2701+
},
2702+
},
2703+
}
2704+
2705+
// Setup test clients
2706+
tdata := testclient.Data{
2707+
PipelineRuns: []*tektonv1.PipelineRun{pr, failedPR},
2708+
Repositories: []*v1alpha1.Repository{repo},
2709+
}
2710+
stdata, _ := testclient.SeedTestData(t, ctx, tdata)
2711+
2712+
cs := &params.Run{
2713+
Clients: clients.Clients{
2714+
Log: logger,
2715+
Tekton: stdata.Pipeline,
2716+
Kube: stdata.Kube,
2717+
},
2718+
}
2719+
2720+
tests := []struct {
2721+
name string
2722+
eventType string
2723+
sha string
2724+
wantPR bool
2725+
}{
2726+
{
2727+
name: "Retest command with matching SHA should find successful PR",
2728+
eventType: opscomments.RetestAllCommentEventType.String(),
2729+
sha: "test-sha",
2730+
wantPR: true,
2731+
},
2732+
{
2733+
name: "Ok-to-test command with matching SHA should find successful PR",
2734+
eventType: opscomments.OkToTestCommentEventType.String(),
2735+
sha: "test-sha",
2736+
wantPR: true,
2737+
},
2738+
{
2739+
name: "Retest command with non-matching SHA should not find PR",
2740+
eventType: opscomments.RetestAllCommentEventType.String(),
2741+
sha: "other-sha",
2742+
wantPR: false,
2743+
},
2744+
{
2745+
name: "Different event type should not find PR",
2746+
eventType: opscomments.TestAllCommentEventType.String(),
2747+
sha: "test-sha",
2748+
wantPR: false,
2749+
},
2750+
}
2751+
2752+
for _, tt := range tests {
2753+
t.Run(tt.name, func(t *testing.T) {
2754+
event := &info.Event{
2755+
EventType: tt.eventType,
2756+
SHA: tt.sha,
2757+
}
2758+
2759+
foundPR := checkForExistingSuccessfulPipelineRun(ctx, logger, cs, event, repo)
2760+
2761+
if tt.wantPR && foundPR == nil {
2762+
t.Errorf("Expected to find a successful PipelineRun, but got nil")
2763+
}
2764+
2765+
if !tt.wantPR && foundPR != nil {
2766+
t.Errorf("Expected not to find a PipelineRun, but found %s", foundPR.Name)
2767+
}
2768+
2769+
if tt.wantPR && foundPR != nil {
2770+
assert.Equal(t, "test-pr", foundPR.Name)
2771+
}
2772+
})
2773+
}
2774+
}

pkg/pipelineascode/pipelineascode.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,21 @@ func NewPacs(event *info.Event, vcx provider.Interface, run *params.Run, pacInfo
5656
}
5757

5858
func (p *PacRun) Run(ctx context.Context) error {
59-
matchedPRs, repo, err := p.matchRepoPR(ctx)
60-
if repo != nil && p.event.TriggerTarget == triggertype.PullRequestClosed {
61-
if err := p.cancelAllInProgressBelongingToClosedPullRequest(ctx, repo); err != nil {
62-
return fmt.Errorf("error cancelling in progress pipelineRuns belonging to pull request %d: %w", p.event.PullRequestNumber, err)
59+
// For PullRequestClosed events, skip matching logic and go straight to cancellation
60+
if p.event.TriggerTarget == triggertype.PullRequestClosed {
61+
repo, err := p.verifyRepoAndUser(ctx)
62+
if err != nil {
63+
return err
64+
}
65+
if repo != nil {
66+
if err := p.cancelAllInProgressBelongingToClosedPullRequest(ctx, repo); err != nil {
67+
return fmt.Errorf("error cancelling in progress pipelineRuns belonging to pull request %d: %w", p.event.PullRequestNumber, err)
68+
}
6369
}
6470
return nil
6571
}
72+
73+
matchedPRs, repo, err := p.matchRepoPR(ctx)
6674
if err != nil {
6775
createStatusErr := p.vcx.CreateStatus(ctx, p.event, provider.StatusOpts{
6876
Status: CompletedStatus,

0 commit comments

Comments
 (0)