Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: Report validation errors on pull requests as comment #1963

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
4 changes: 3 additions & 1 deletion docs/content/docs/dev/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,14 @@ For example, to test and lint the go files:
make test lint-go
```

If you add a CLI command with help, you will need to regenerate the golden files:
We use [golden](https://pkg.go.dev/gotest.tools/v3/golden) files in our tests, for instance, to compare the output of CLI commands or other detailed tests. Occasionally, you may need to regenerate the golden files if you modify the output of a command. For unit tests, you can use this Makefile target:

```shell
make update-golden
```

Head over to the [./test/README.md](./test/README.md) for more information on how to update the golden files on the E2E tests.

## Configuring the Pre Push Git checks

We are using several tools to verify that pipelines-as-code is up to a good
Expand Down
35 changes: 29 additions & 6 deletions docs/content/docs/guide/running.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,26 @@ PipelineRun, another user who does have the necessary permissions can comment
The `OWNERS` file follows a specific format similar to the Prow `OWNERS` file
format (detailed at <https://www.kubernetes.dev/docs/guide/owners/>). We
support a basic `OWNERS` configuration with `approvers` and `reviewers` lists,
both of which have equal permissions for executing a `PipelineRun`.
both of which have equal permissions for executing a `PipelineRun`.

If the `OWNERS` file uses `filters` instead of a simple configuration, we only
consider the `.*` filter and extract the `approvers` and `reviewers` lists from
it. Any other filters targeting specific files or directories are ignored.
it. Any other filters targeting specific files or directories are ignored.

Additionally, `OWNERS_ALIASES` is supported and allows mapping alias names to
lists of usernames.
lists of usernames.

Including contributors in the `approvers` or `reviewers` lists within your
`OWNERS` file grants them the ability to execute a `PipelineRun` via
Pipelines-as-Code.
Pipelines-as-Code.

For example, if your repository’s `main` or `master` branch contains the
following `approvers` section:
following `approvers` section:

```yaml
approvers:
- approved
```
```

The user with the username `"approved"` will have the necessary
permissions.
Expand Down Expand Up @@ -115,6 +115,29 @@ or on OpenShift using the OpenShift Console.
Pipelines-as-Code will post a URL in the Checks tab for GitHub apps to let you
click on it and follow the pipeline execution directly there.

## Errors When Parsing PipelineRun YAML

When Pipelines-As-Code encounters an issue with the YAML formatting in the
repository, it will log the error in the user namespace events log and
the Pipelines-as-Code controller log.

Despite the error, Pipelines-As-Code will continue to run other correctly parsed
and matched PipelineRuns.

{{< support_matrix github_app="true" github_webhook="true" gitea="true" gitlab="true" bitbucket_cloud="false" bitbucket_server="false" >}}

When an event is triggered from a Pull Request, a new comment will be created on
the Pull Request detailing the error.

Subsequent iterations on the Pull Request will update the comment with any new
errors.

If no new errors are detected, the comment will remain and will not be deleted.

Here is an example of a YAML error being reported as a comment to a Pull Request:

![report yaml error as comments](/images/report-error-comment-on-bad-yaml.png)

## Cancelling

### Cancelling in-progress PipelineRuns
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 39 additions & 10 deletions pkg/pipelineascode/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"

apipac "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/keys"
Expand All @@ -20,6 +21,12 @@ import (
"go.uber.org/zap"
)

const validationErrorTemplate = `> [!CAUTION]
> There are some errors in your PipelineRun template.

| PipelineRun | Error |
|------|-------|`

func (p *PacRun) matchRepoPR(ctx context.Context) ([]matcher.Match, *v1alpha1.Repository, error) {
repo, err := p.verifyRepoAndUser(ctx)
if err != nil {
Expand Down Expand Up @@ -172,10 +179,16 @@ func (p *PacRun) getPipelineRunsFromRepo(ctx context.Context, repo *v1alpha1.Rep
rawTemplates, err := p.vcx.GetTektonDir(ctx, p.event, tektonDir, provenance)
if err != nil && strings.Contains(err.Error(), "error unmarshalling yaml file") {
// make the error a bit more friendly for users who don't know what marshalling or intricacies of the yaml parser works
errmsg := err.Error()
errmsg = strings.ReplaceAll(errmsg, " error converting YAML to JSON: yaml:", "")
errmsg = strings.ReplaceAll(errmsg, "unmarshalling", "while parsing the")
return nil, fmt.Errorf("%s", errmsg)
// format is "error unmarshalling yaml file pr-bad-format.yaml: yaml: line 3: could not find expected ':'"
// get the filename with a regexp
reg := regexp.MustCompile(`error unmarshalling yaml file\s([^:]*):\s*(yaml:\s*)?(.*)`)
matches := reg.FindStringSubmatch(err.Error())
if len(matches) == 4 {
p.reportValidationErrors(ctx, repo, map[string]string{matches[1]: matches[3]})
return nil, nil
}

return nil, err
}
if err != nil || rawTemplates == "" {
msg := fmt.Sprintf("cannot locate templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
Expand Down Expand Up @@ -225,15 +238,12 @@ func (p *PacRun) getPipelineRunsFromRepo(ctx context.Context, repo *v1alpha1.Rep
return nil, err
}

if types.ValidationErrors != nil {
for k, v := range types.ValidationErrors {
kv := fmt.Sprintf("prun: %s tekton validation error: %s", k, v)
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunValidationErrors", kv)
}
if len(types.ValidationErrors) > 0 && p.event.TriggerTarget == triggertype.PullRequest {
p.reportValidationErrors(ctx, repo, types.ValidationErrors)
}
pipelineRuns := types.PipelineRuns
if len(pipelineRuns) == 0 {
msg := fmt.Sprintf("cannot locate templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
msg := fmt.Sprintf("cannot locate valid templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
p.eventEmitter.EmitMessage(nil, zap.InfoLevel, "RepositoryCannotLocatePipelineRun", msg)
return nil, nil
}
Expand Down Expand Up @@ -430,3 +440,22 @@ func (p *PacRun) createNeutralStatus(ctx context.Context) error {

return nil
}

// reportValidationErrors reports validation errors found in PipelineRuns by:
// 1. Creating error messages for each validation error
// 2. Emitting error messages to the event system
// 3. Creating a markdown formatted comment on the repository with all errors.
func (p *PacRun) reportValidationErrors(ctx context.Context, repo *v1alpha1.Repository, validationErrors map[string]string) {
errorRows := make([]string, 0, len(validationErrors))
for name, err := range validationErrors {
errorRows = append(errorRows, fmt.Sprintf("| %s | `%s` |", name, err))
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunValidationErrors",
fmt.Sprintf("cannot read the PipelineRun: %s, error: %s", name, err))
}
markdownErrMessage := fmt.Sprintf(`%s
%s`, validationErrorTemplate, strings.Join(errorRows, "\n"))
if err := p.vcx.CreateComment(ctx, p.event, markdownErrMessage, validationErrorTemplate); err != nil {
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunValidationErrors",
fmt.Sprintf("failed to create comment: %s", err.Error()))
}
}
2 changes: 1 addition & 1 deletion pkg/pipelineascode/match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func TestGetPipelineRunsFromRepo(t *testing.T) {
},
tektondir: "testdata/invalid_tekton_yaml",
event: pullRequestEvent,
logSnippet: `prun: bad-tekton-yaml tekton validation error: json: cannot unmarshal object into Go struct field PipelineSpec.spec.pipelineSpec.tasks of type []v1beta1.PipelineTask`,
logSnippet: `json: cannot unmarshal object into Go struct field PipelineSpec.spec.pipelineSpec.tasks of type []v1beta1.PipelineTask`,
},
{
name: "no-match pipelineruns in .tekton dir, only matched should be returned",
Expand Down
38 changes: 33 additions & 5 deletions pkg/pipelineascode/pipelineascode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -63,11 +64,6 @@ func testSetupCommonGhReplies(t *testing.T, mux *http.ServeMux, runevent info.Ev
fmt.Sprintf("/repos/%s/%s/statuses/%s", runevent.Organization, runevent.Repository, runevent.SHA),
"{}")

// using 666 as pull request number
replyString(mux,
fmt.Sprintf("/repos/%s/%s/issues/666/comments", runevent.Organization, runevent.Repository),
"{}")

jj := fmt.Sprintf(`{"sha": "%s", "html_url": "https://git.commit.url/%s", "message": "commit message"}`,
runevent.SHA, runevent.SHA)
replyString(mux,
Expand Down Expand Up @@ -131,6 +127,7 @@ func TestRun(t *testing.T) {
PayloadEncodedSecret string
concurrencyLimit int
expectedLogSnippet string
expectedPostedComment string // TODO: multiple posted comments when we need it
}{
{
name: "pull request/fail-to-start-apps",
Expand All @@ -149,6 +146,23 @@ func TestRun(t *testing.T) {
finalStatus: "failure",
finalStatusText: "we need at least one pipelinerun to start with",
},
{
name: "pull request/bad-yaml",
runevent: info.Event{
SHA: "principale",
Organization: "owner",
Repository: "lagaffe",
URL: "https://service/documentation",
HeadBranch: "press",
BaseBranch: "main",
Sender: "owner",
EventType: "pull_request",
TriggerTarget: "pull_request",
PullRequestNumber: 666,
},
tektondir: "testdata/bad_yaml",
expectedPostedComment: ".*There are some errors in your PipelineRun template.*line 2: did not find expected key",
},
{
name: "pull request/unknown-remotetask-but-fail-on-matching",
runevent: info.Event{
Expand Down Expand Up @@ -548,6 +562,20 @@ func TestRun(t *testing.T) {
ghtesthelper.SetupGitTree(t, mux, tt.tektondir, &tt.runevent, false)
}

// using 666 as pull request number
mux.HandleFunc(fmt.Sprintf("/repos/%s/%s/issues/%d/comments", tt.runevent.Organization, tt.runevent.Repository, tt.runevent.PullRequestNumber),
func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
_, _ = fmt.Fprintf(w, `{"id": %d}`, tt.runevent.PullRequestNumber)
// read body and compare it
body, _ := io.ReadAll(req.Body)
expectedRegexp := regexp.MustCompile(tt.expectedPostedComment)
assert.Assert(t, expectedRegexp.Match(body), "expected comment %s, got %s", tt.expectedPostedComment, string(body))
return
}
_, _ = fmt.Fprint(w, `[]`)
})

stdata, _ := testclient.SeedTestData(t, ctx, tdata)
cs := &params.Run{
Clients: clients.Clients{
Expand Down
4 changes: 4 additions & 0 deletions pkg/provider/bitbucketcloud/bitbucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ type Provider struct {
provenance string
}

func (v *Provider) CreateComment(_ context.Context, _ *info.Event, _, _ string) error {
return nil
}

// CheckPolicyAllowing TODO: Implement ME.
func (v *Provider) CheckPolicyAllowing(_ context.Context, _ *info.Event, _ []string) (bool, string) {
return false, ""
Expand Down
4 changes: 4 additions & 0 deletions pkg/provider/bitbucketserver/bitbucketserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ type Provider struct {
projectKey string
}

func (v *Provider) CreateComment(_ context.Context, _ *info.Event, _, _ string) error {
return nil
}

func (v *Provider) SetPacInfo(pacInfo *info.PacOpts) {
v.pacInfo = pacInfo
}
Expand Down
35 changes: 35 additions & 0 deletions pkg/provider/gitea/gitea.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"path"
"regexp"
"strconv"
"strings"

Expand Down Expand Up @@ -55,6 +56,40 @@ type Provider struct {
run *params.Run
}

func (v *Provider) CreateComment(_ context.Context, event *info.Event, commit, updateMarker string) error {
if v.Client == nil {
return fmt.Errorf("no gitea client has been initialized")
}

if event.PullRequestNumber == 0 {
return fmt.Errorf("create comment only works on pull requests")
}

// List comments of the PR
if updateMarker != "" {
comments, _, err := v.Client.ListIssueComments(event.Organization, event.Repository, int64(event.PullRequestNumber), gitea.ListIssueCommentOptions{})
if err != nil {
return err
}

for _, comment := range comments {
re := regexp.MustCompile(updateMarker)
if re.MatchString(comment.Body) {
_, _, err := v.Client.EditIssueComment(event.Organization, event.Repository, comment.ID, gitea.EditIssueCommentOption{
Body: commit,
})
return err
}
}
}

_, _, err := v.Client.CreateIssueComment(event.Organization, event.Repository, int64(event.PullRequestNumber), gitea.CreateIssueCommentOption{
Body: commit,
})

return err
}

func (v *Provider) SetPacInfo(pacInfo *info.PacOpts) {
v.pacInfo = pacInfo
}
Expand Down
Loading
Loading