Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/300-repositories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,11 @@ spec:
- update
type: string
type: object
gitops_command_prefix:
description: |-
GitOpsCommandPrefix configures the prefix for the GitOps command.
This is used to identify the GitOps command in the PipelineRun.
type: string
pipelinerun_provenance:
description: |-
PipelineRunProvenance configures how PipelineRun definitions are fetched.
Expand Down
43 changes: 43 additions & 0 deletions docs/content/docs/guides/gitops-commands/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ This ensures the comment is correctly formatted when processed.

For a practical example, see the [pac-boussole](https://github.com/openshift-pipelines/pac-boussole) project, which uses the `on-comment` annotation to create a PipelineRun experience similar to [Prow](https://docs.prow.k8s.io/).

## GitOps Command Prefix

{{< support_matrix github_app="true" github_webhook="true" forgejo="false" gitlab="false" bitbucket_cloud="false" bitbucket_datacenter="false" >}}

You can configure a custom prefix for GitOps commands in the Repository CR. This allows you to use commands like `/pac test` instead of the standard `/test`. This is useful when both [prow](https://docs.prow.k8s.io/) CI and Pipelines-as-Code are configured on a Repository and making comments causes issues and confusion.

Please note that custom GitOps commands are excluded from this prefix settings.

To configure a custom GitOps command prefix, set the `gitops_command_prefix` field in your Repository CR's `settings` section:

```yaml
apiVersion: "pipelinesascode.tekton.dev/v1alpha1"
kind: Repository
metadata:
name: my-repository
namespace: pipelines-as-code
spec:
url: "https://github.com/organization/repository"
settings:
gitops_command_prefix: "pac"
```

Note: Set the prefix as a plain word (e.g. `pac`). The Forward slash (`/`) is added automatically by Pipelines-as-Code.

With this configuration, you can use the following prefixed commands:

- `/pac test` - Trigger all matching PipelineRuns
- `/pac test <pipelinerun-name>` - Trigger a specific PipelineRun
- `/pac retest` - Retest failed PipelineRuns
- `/pac retest <pipelinerun-name>` - Retest specific PipelineRun
- `/pac cancel` - Cancel all running PipelineRuns
- `/pac cancel <pipelinerun-name>` - Cancel Specific PipelineRun
- `/pac ok-to-test` - Approve CI for external contributors
- `/pac ok-to-test SHA` - Approve CI for external contributors for a specific SHA

Example:

```text
/pac test
```

You can also configure GitOps command prefix in [Global Repository CR]({{< relref "/docs/operations/global-repository-settings" >}}) so that it will be applied to all Repository CRs those are not defining their own prefix.

## Cancelling a PipelineRun

**What it does:** The `/cancel` command stops running PipelineRuns by commenting on the pull request.
Expand Down
9 changes: 9 additions & 0 deletions pkg/apis/pipelinesascode/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ type Settings struct {
// +kubebuilder:validation:Enum=source;default_branch
PipelineRunProvenance string `json:"pipelinerun_provenance,omitempty"`

// GitOpsCommandPrefix configures the prefix for the GitOps command.
// This is used to identify the GitOps command in the PipelineRun.
// +optional
GitOpsCommandPrefix string `json:"gitops_command_prefix,omitempty"`

// Policy defines authorization policies for the repository, controlling who can
// trigger PipelineRuns under different conditions.
// +optional
Expand Down Expand Up @@ -223,6 +228,10 @@ func (s *Settings) Merge(newSettings *Settings) {
if newSettings.AIAnalysis != nil && s.AIAnalysis == nil {
s.AIAnalysis = newSettings.AIAnalysis
}

if newSettings.GitOpsCommandPrefix != "" && s.GitOpsCommandPrefix == "" {
s.GitOpsCommandPrefix = newSettings.GitOpsCommandPrefix
}
}

type Policy struct {
Expand Down
4 changes: 4 additions & 0 deletions pkg/apis/pipelinesascode/v1alpha1/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func TestMergeSpecs(t *testing.T) {
Policy: &Policy{
OkToTest: []string{"ok1", "ok2"},
},
GitOpsCommandPrefix: "pac",
}, // Initialize as needed
GitProvider: gp, // Initialize as needed
Incomings: incomings,
Expand All @@ -71,6 +72,7 @@ func TestMergeSpecs(t *testing.T) {
Policy: &Policy{
OkToTest: []string{"ok1", "ok2"},
},
GitOpsCommandPrefix: "pac",
},
Incomings: incomings,
GitProvider: gp,
Expand All @@ -87,6 +89,7 @@ func TestMergeSpecs(t *testing.T) {
Policy: &Policy{
OkToTest: []string{"ok1", "ok2"},
},
GitOpsCommandPrefix: "pac",
}, // Initialize as needed
GitProvider: &GitProvider{}, // Initialize as needed
},
Expand All @@ -110,6 +113,7 @@ func TestMergeSpecs(t *testing.T) {
Policy: &Policy{
OkToTest: []string{"ok1", "ok2"},
},
GitOpsCommandPrefix: "pac",
},
Incomings: incomings,
GitProvider: gp,
Expand Down
20 changes: 15 additions & 5 deletions pkg/matcher/annotation_matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package matcher

import (
"context"
"errors"
"fmt"
"regexp"
"strings"
Expand Down Expand Up @@ -37,9 +36,20 @@ const (
maxCommentLogLength = 160
)

// ErrNoFailedPipelineToRetest is returned when /retest or /ok-to-test is used
// but all matching pipelines have already succeeded for this commit.
var ErrNoFailedPipelineToRetest = errors.New("All PipelineRuns for this commit have already succeeded. Use `/retest <pipeline-name>` to re-run a specific pipeline or `/test` to re-run all pipelines.") // nolint:revive,staticcheck
// NoFailedPipelineToRetestError is an error type returned when /retest or
// /ok-to-test is used but all matching pipelines have already succeeded for
// this commit. The underlying string value is the gitops comment prefix used
// to construct the user-facing error message with the correct command syntax.
type NoFailedPipelineToRetestError string

func (e NoFailedPipelineToRetestError) Error() string {
return fmt.Sprintf("All PipelineRuns for this commit have already succeeded. Use `%sretest <pipeline-name>` to re-run a specific pipeline or `%stest` to re-run all pipelines.", string(e), string(e))
}

func (e NoFailedPipelineToRetestError) Is(target error) bool {
_, ok := target.(NoFailedPipelineToRetestError)
return ok
}

// prunBranch is value from annotations and baseBranch is event.Base value from event.
func branchMatch(prunBranch, baseBranch string) bool {
Expand Down Expand Up @@ -426,7 +436,7 @@ func MatchPipelinerunByAnnotation(ctx context.Context, logger *zap.SugaredLogger
logger.Debugf("MatchPipelinerunByAnnotation: filtering successful templates for event_type=%s", event.EventType)
filtered := filterSuccessfulTemplates(ctx, logger, cs, event, repo, matchedPRs)
if len(filtered) == 0 {
return nil, ErrNoFailedPipelineToRetest
return nil, NoFailedPipelineToRetestError(provider.GetGitOpsCommentPrefix(repo))
}
return filtered, nil
}
Expand Down
9 changes: 7 additions & 2 deletions pkg/matcher/annotation_matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2281,7 +2281,12 @@ func TestMatchPipelinerunByAnnotation(t *testing.T) {
wantErrNoFailedPipelineToRetest: true,
repo: &v1alpha1.Repository{
ObjectMeta: metav1.ObjectMeta{Name: "test-repo", Namespace: "test-ns"},
Spec: v1alpha1.RepositorySpec{URL: "https://github.com/org/repo"},
Spec: v1alpha1.RepositorySpec{
URL: "https://github.com/org/repo",
Settings: &v1alpha1.Settings{
GitOpsCommandPrefix: "pac",
},
},
},
seedData: &testclient.Data{
PipelineRuns: []*tektonv1.PipelineRun{
Expand Down Expand Up @@ -2335,7 +2340,7 @@ func TestMatchPipelinerunByAnnotation(t *testing.T) {
matches, err := MatchPipelinerunByAnnotation(ctx, logger, tt.args.pruns, cs, &tt.args.runevent, &ghprovider.Provider{}, eventEmitter, repo, true)
if tt.wantErrNoFailedPipelineToRetest {
assert.Assert(t, err != nil, "expected ErrNoFailedPipelineToRetest")
assert.Assert(t, errors.Is(err, ErrNoFailedPipelineToRetest), "expected ErrNoFailedPipelineToRetest, got: %v", err)
assert.Assert(t, errors.Is(err, NoFailedPipelineToRetestError("/pac ")), "expected ErrNoFailedPipelineToRetest, got: %v", err)
assert.Equal(t, len(matches), 0, "expected no matches when all pipelines already succeeded")
return
}
Expand Down
151 changes: 60 additions & 91 deletions pkg/opscomments/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,11 @@ import (
"regexp"
"strings"

"go.uber.org/zap"

"github.com/openshift-pipelines/pipelines-as-code/pkg/acl"
"github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
"github.com/openshift-pipelines/pipelines-as-code/pkg/events"
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/info"
"github.com/openshift-pipelines/pipelines-as-code/pkg/params/triggertype"
)

var (
testAllRegex = regexp.MustCompile(`(?m)^/test\s*$`)
retestAllRegex = regexp.MustCompile(`(?m)^/retest\s*$`)
testSingleRegex = regexp.MustCompile(`(?m)^/test[ \t]+\S+`)
retestSingleRegex = regexp.MustCompile(`(?m)^/retest[ \t]+\S+`)
oktotestRegex = regexp.MustCompile(acl.OKToTestCommentRegexp)
cancelAllRegex = regexp.MustCompile(`(?m)^(/cancel)\s*$`)
cancelSingleRegex = regexp.MustCompile(`(?m)^(/cancel)[ \t]+\S+`)
"go.uber.org/zap"
)

type EventType string
Expand All @@ -42,56 +30,93 @@ var (
OkToTestCommentEventType = EventType("ok-to-test-comment")
)

const (
testComment = "/test"
retestComment = "/retest"
cancelComment = "/cancel"
)
func RetestAllRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%sretest\s*$`, prefix))
}

func RetestSingleRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%sretest[ \t]+\S+`, prefix))
}

func TestAllRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%stest\s*$`, prefix))
}

func TestSingleRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%stest[ \t]+\S+`, prefix))
}

func OkToTestRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(^|\n)\s*%sok-to-test(?:\s+([a-fA-F0-9]{7,40}))?\s*(\r\n|\r|\n|$)`, prefix))
}

func CancelAllRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%scancel\s*$`, prefix))
}

func CancelSingleRegex(prefix string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf(`(?m)^\s*%scancel[ \t]+\S+`, prefix))
}
Comment on lines +33 to +59

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These functions use regexp.MustCompile to compile regular expressions on every invocation. Since CommentEventType calls these functions sequentially until a match is found, multiple regexes are compiled for every comment event processed by the webhook. This introduces significant overhead. It is recommended to cache the compiled regular expressions, for example by using a map keyed by the prefix.


func CommentEventType(comment string) EventType {
func CommentEventType(comment, prefix string) EventType {
switch {
case retestAllRegex.MatchString(comment):
case RetestAllRegex(prefix).MatchString(comment):
return RetestAllCommentEventType
case retestSingleRegex.MatchString(comment):
case RetestSingleRegex(prefix).MatchString(comment):
return RetestSingleCommentEventType
case testAllRegex.MatchString(comment):
case TestAllRegex(prefix).MatchString(comment):
return TestAllCommentEventType
case testSingleRegex.MatchString(comment):
case TestSingleRegex(prefix).MatchString(comment):
return TestSingleCommentEventType
case oktotestRegex.MatchString(comment):
case OkToTestRegex(prefix).MatchString(comment):
return OkToTestCommentEventType
case cancelAllRegex.MatchString(comment):
case CancelAllRegex(prefix).MatchString(comment):
return CancelCommentAllEventType
case cancelSingleRegex.MatchString(comment):
case CancelSingleRegex(prefix).MatchString(comment):
return CancelCommentSingleEventType
default:
return NoOpsCommentEventType
}
}

// SetEventTypeAndTargetPR function will set the event type and target test pipeline run in an event.
func SetEventTypeAndTargetPR(event *info.Event, comment string) {
commentType := CommentEventType(comment)
if commentType == RetestSingleCommentEventType || commentType == TestSingleCommentEventType {
event.TargetTestPipelineRun = GetPipelineRunFromTestComment(comment)
func SetEventTypeAndTargetPR(event *info.Event, comment, prefix string) {
commentType := CommentEventType(comment, prefix)
if commentType == RetestSingleCommentEventType {
typeOfComment := prefix + "retest"
event.TargetTestPipelineRun = getNameFromComment(typeOfComment, comment)
}
if commentType == TestSingleCommentEventType {
typeOfComment := prefix + "test"
event.TargetTestPipelineRun = getNameFromComment(typeOfComment, comment)
}
if commentType == CancelCommentAllEventType || commentType == CancelCommentSingleEventType {
event.CancelPipelineRuns = true
}
if commentType == CancelCommentSingleEventType {
event.TargetCancelPipelineRun = GetPipelineRunFromCancelComment(comment)
typeOfComment := prefix + "cancel"
event.TargetCancelPipelineRun = getNameFromComment(typeOfComment, comment)
}
event.EventType = commentType.String()
event.TriggerComment = comment
}

func IsOkToTestComment(comment string) bool {
return oktotestRegex.MatchString(comment)
func IsOkToTestComment(comment, prefix string) bool {
return OkToTestRegex(prefix).MatchString(comment)
}

func IsTestRetestComment(comment, prefix string) bool {
return TestSingleRegex(prefix).MatchString(comment) || TestAllRegex(prefix).MatchString(comment) ||
RetestSingleRegex(prefix).MatchString(comment) || RetestAllRegex(prefix).MatchString(comment)
}

func IsCancelComment(comment, prefix string) bool {
return CancelAllRegex(prefix).MatchString(comment) || CancelSingleRegex(prefix).MatchString(comment)
}

// GetSHAFromOkToTestComment extracts the optional SHA from an /ok-to-test comment.
func GetSHAFromOkToTestComment(comment string) string {
matches := oktotestRegex.FindStringSubmatch(comment)
func GetSHAFromOkToTestComment(comment, prefix string) string {
matches := OkToTestRegex(prefix).FindStringSubmatch(comment)
if len(matches) > 2 {
return strings.TrimSpace(matches[2])
}
Expand Down Expand Up @@ -143,17 +168,6 @@ func AnyOpsKubeLabelInSelector() string {
OnCommentEventType.String())
}

func GetPipelineRunFromTestComment(comment string) string {
if strings.Contains(comment, testComment) {
return getNameFromComment(testComment, comment)
}
return getNameFromComment(retestComment, comment)
}

func GetPipelineRunFromCancelComment(comment string) string {
return getNameFromComment(cancelComment, comment)
}

func getNameFromComment(typeOfComment, comment string) string {
splitTest := strings.Split(strings.TrimSpace(comment), typeOfComment)
if len(splitTest) < 2 {
Expand All @@ -171,48 +185,3 @@ func getNameFromComment(typeOfComment, comment string) string {
// trim spaces
return strings.TrimSpace(firstArg[1])
}

func GetPipelineRunAndBranchNameFromTestComment(comment string) (string, string, error) {
if strings.Contains(comment, testComment) {
return getPipelineRunAndBranchNameFromComment(testComment, comment)
}
return getPipelineRunAndBranchNameFromComment(retestComment, comment)
}

func GetPipelineRunAndBranchNameFromCancelComment(comment string) (string, string, error) {
return getPipelineRunAndBranchNameFromComment(cancelComment, comment)
}

// getPipelineRunAndBranchNameFromComment function will take GitOps comment and split the comment
// by /test, /retest or /cancel to return branch name and pipelinerun name.
func getPipelineRunAndBranchNameFromComment(typeOfComment, comment string) (string, string, error) {
var prName, branchName string
splitTest := strings.Split(comment, typeOfComment)

// after the split get the second part of the typeOfComment (/test, /retest or /cancel)
// as second part can be branch name or pipelinerun name and branch name
// ex: /test branch:nightly, /test prname branch:nightly
if splitTest[1] != "" && strings.Contains(splitTest[1], ":") {
branchData := strings.Split(splitTest[1], ":")

// make sure no other word is supported other than branch word
if !strings.Contains(branchData[0], "branch") {
return prName, branchName, fmt.Errorf("the GitOps comment%s does not contain a branch word", branchData[0])
}
branchName = strings.Split(strings.TrimSpace(branchData[1]), " ")[0]

// if data after the split contains prname then fetch that
prData := strings.Split(strings.TrimSpace(branchData[0]), " ")
if len(prData) > 1 {
prName = strings.TrimSpace(prData[0])
}
} else {
// get the second part of the typeOfComment (/test, /retest or /cancel)
// as second part contains pipelinerun name
// ex: /test prname
getFirstLine := strings.Split(splitTest[1], "\n")
// trim spaces
prName = strings.TrimSpace(getFirstLine[0])
}
return prName, branchName, nil
}
Loading
Loading