Skip to content

Commit aea95cd

Browse files
committed
feat: Report validation errors on pull requests
This commit introduces the capability to report PipelineRun validation errors as comments on pull requests. This functionality is triggered when validation errors are detected and the event type is a pull request. The errors are formatted into a markdown table and posted as a comment on the pull request, allowing users to easily identify and address issues in their PipelineRun templates. The commit also includes updates to the provider interfaces to include a CreateComment function, and implementations for GitHub, GitLab, Bitbucket Cloud, Bitbucket Server and Gitea providers. Signed-off-by: Chmouel Boudjnah <chmouel@redhat.com>
1 parent 180060a commit aea95cd

File tree

21 files changed

+624
-48
lines changed

21 files changed

+624
-48
lines changed

docs/content/docs/dev/_index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,14 @@ For example, to test and lint the go files:
189189
make test lint-go
190190
```
191191

192-
If you add a CLI command with help, you will need to regenerate the golden files:
192+
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:
193193

194194
```shell
195195
make update-golden
196196
```
197197

198+
Head over to the [./test/README.md](./test/README.md) for more information on how to update the golden files on the E2E tests.
199+
198200
## Configuring the Pre Push Git checks
199201

200202
We are using several tools to verify that pipelines-as-code is up to a good

docs/content/docs/guide/running.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ If the `OWNERS` file uses `filters` instead of a simple configuration, we only
7979
consider the `.*` filter and extract the `approvers` and `reviewers` lists from
8080
it. Any other filters targeting specific files or directories are ignored.
8181

82-
Additionally, `OWNERS_ALIASES` is supported and allows mapping alias names to
82+
Additionally, `OWNERS_ALIASES` is supported and allows mapping alias names to a
8383
lists of usernames.
8484

8585
Including contributors in the `approvers` or `reviewers` lists within your
@@ -122,6 +122,29 @@ or on OpenShift using the OpenShift Console.
122122
Pipelines-as-Code will post a URL in the Checks tab for GitHub apps to let you
123123
click on it and follow the pipeline execution directly there.
124124

125+
## Errors When Parsing PipelineRun YAML
126+
127+
When Pipelines-As-Code encounters an issue with the YAML formatting in the
128+
repository, it will log the error in the user namespace events log and
129+
the Pipelines-as-Code controller log.
130+
131+
Despite the error, Pipelines-As-Code will continue to run other correctly parsed
132+
and matched PipelineRuns.
133+
134+
{{< support_matrix github_app="true" github_webhook="true" gitea="true" gitlab="true" bitbucket_cloud="false" bitbucket_server="false" >}}
135+
136+
When an event is triggered from a Pull Request, a new comment will be created on
137+
the Pull Request detailing the error.
138+
139+
Subsequent iterations on the Pull Request will update the comment with any new
140+
errors.
141+
142+
If no new errors are detected, the comment will remain and will not be deleted.
143+
144+
Here is an example of a YAML error being reported as a comment to a Pull Request:
145+
146+
![report yaml error as comments](/images/report-error-comment-on-bad-yaml.png)
147+
125148
## Cancelling
126149

127150
### Cancelling in-progress PipelineRuns
96.9 KB
Loading

pkg/pipelineascode/match.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"regexp"
78
"strings"
89

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

24+
const validationErrorTemplate = `> [!CAUTION]
25+
> There are some errors in your PipelineRun template.
26+
27+
| PipelineRun | Error |
28+
|------|-------|`
29+
2330
func (p *PacRun) matchRepoPR(ctx context.Context) ([]matcher.Match, *v1alpha1.Repository, error) {
2431
repo, err := p.verifyRepoAndUser(ctx)
2532
if err != nil {
@@ -172,12 +179,18 @@ func (p *PacRun) getPipelineRunsFromRepo(ctx context.Context, repo *v1alpha1.Rep
172179
provenance = repo.Spec.Settings.PipelineRunProvenance
173180
}
174181
rawTemplates, err := p.vcx.GetTektonDir(ctx, p.event, tektonDir, provenance)
175-
if err != nil && strings.Contains(err.Error(), "error unmarshalling yaml file") {
182+
if err != nil && p.event.TriggerTarget == triggertype.PullRequest && strings.Contains(err.Error(), "error unmarshalling yaml file") {
176183
// make the error a bit more friendly for users who don't know what marshalling or intricacies of the yaml parser works
177-
errmsg := err.Error()
178-
errmsg = strings.ReplaceAll(errmsg, " error converting YAML to JSON: yaml:", "")
179-
errmsg = strings.ReplaceAll(errmsg, "unmarshalling", "while parsing the")
180-
return nil, fmt.Errorf("%s", errmsg)
184+
// format is "error unmarshalling yaml file pr-bad-format.yaml: yaml: line 3: could not find expected ':'"
185+
// get the filename with a regexp
186+
reg := regexp.MustCompile(`error unmarshalling yaml file\s([^:]*):\s*(yaml:\s*)?(.*)`)
187+
matches := reg.FindStringSubmatch(err.Error())
188+
if len(matches) == 4 {
189+
p.reportValidationErrors(ctx, repo, map[string]string{matches[1]: matches[3]})
190+
return nil, nil
191+
}
192+
193+
return nil, err
181194
}
182195
if err != nil || rawTemplates == "" {
183196
msg := fmt.Sprintf("cannot locate templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
@@ -227,15 +240,12 @@ func (p *PacRun) getPipelineRunsFromRepo(ctx context.Context, repo *v1alpha1.Rep
227240
return nil, err
228241
}
229242

230-
if types.ValidationErrors != nil {
231-
for k, v := range types.ValidationErrors {
232-
kv := fmt.Sprintf("prun: %s tekton validation error: %s", k, v)
233-
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunValidationErrors", kv)
234-
}
243+
if len(types.ValidationErrors) > 0 && p.event.TriggerTarget == triggertype.PullRequest {
244+
p.reportValidationErrors(ctx, repo, types.ValidationErrors)
235245
}
236246
pipelineRuns := types.PipelineRuns
237247
if len(pipelineRuns) == 0 {
238-
msg := fmt.Sprintf("cannot locate templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
248+
msg := fmt.Sprintf("cannot locate valid templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
239249
p.eventEmitter.EmitMessage(nil, zap.InfoLevel, "RepositoryCannotLocatePipelineRun", msg)
240250
return nil, nil
241251
}
@@ -437,3 +447,22 @@ func (p *PacRun) createNeutralStatus(ctx context.Context) error {
437447

438448
return nil
439449
}
450+
451+
// reportValidationErrors reports validation errors found in PipelineRuns by:
452+
// 1. Creating error messages for each validation error
453+
// 2. Emitting error messages to the event system
454+
// 3. Creating a markdown formatted comment on the repository with all errors.
455+
func (p *PacRun) reportValidationErrors(ctx context.Context, repo *v1alpha1.Repository, validationErrors map[string]string) {
456+
errorRows := make([]string, 0, len(validationErrors))
457+
for name, err := range validationErrors {
458+
errorRows = append(errorRows, fmt.Sprintf("| %s | `%s` |", name, err))
459+
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunValidationErrors",
460+
fmt.Sprintf("cannot read the PipelineRun: %s, error: %s", name, err))
461+
}
462+
markdownErrMessage := fmt.Sprintf(`%s
463+
%s`, validationErrorTemplate, strings.Join(errorRows, "\n"))
464+
if err := p.vcx.CreateComment(ctx, p.event, markdownErrMessage, validationErrorTemplate); err != nil {
465+
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunCommentCreationError",
466+
fmt.Sprintf("failed to create comment: %s", err.Error()))
467+
}
468+
}

pkg/pipelineascode/match_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ func TestGetPipelineRunsFromRepo(t *testing.T) {
179179
},
180180
tektondir: "testdata/invalid_tekton_yaml",
181181
event: pullRequestEvent,
182-
logSnippet: `prun: bad-tekton-yaml tekton validation error: json: cannot unmarshal object into Go struct field PipelineSpec.spec.pipelineSpec.tasks of type []v1beta1.PipelineTask`,
182+
logSnippet: `json: cannot unmarshal object into Go struct field PipelineSpec.spec.pipelineSpec.tasks of type []v1beta1.PipelineTask`,
183183
},
184184
{
185185
name: "no-match pipelineruns in .tekton dir, only matched should be returned",

pkg/pipelineascode/pipelineascode_test.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"net/http"
1111
"path/filepath"
12+
"regexp"
1213
"strings"
1314
"sync"
1415
"testing"
@@ -63,11 +64,6 @@ func testSetupCommonGhReplies(t *testing.T, mux *http.ServeMux, runevent info.Ev
6364
fmt.Sprintf("/repos/%s/%s/statuses/%s", runevent.Organization, runevent.Repository, runevent.SHA),
6465
"{}")
6566

66-
// using 666 as pull request number
67-
replyString(mux,
68-
fmt.Sprintf("/repos/%s/%s/issues/666/comments", runevent.Organization, runevent.Repository),
69-
"{}")
70-
7167
jj := fmt.Sprintf(`{"sha": "%s", "html_url": "https://git.commit.url/%s", "message": "commit message"}`,
7268
runevent.SHA, runevent.SHA)
7369
replyString(mux,
@@ -131,6 +127,7 @@ func TestRun(t *testing.T) {
131127
PayloadEncodedSecret string
132128
concurrencyLimit int
133129
expectedLogSnippet string
130+
expectedPostedComment string // TODO: multiple posted comments when we need it
134131
}{
135132
{
136133
name: "pull request/fail-to-start-apps",
@@ -149,6 +146,23 @@ func TestRun(t *testing.T) {
149146
finalStatus: "failure",
150147
finalStatusText: "we need at least one pipelinerun to start with",
151148
},
149+
{
150+
name: "pull request/bad-yaml",
151+
runevent: info.Event{
152+
SHA: "principale",
153+
Organization: "owner",
154+
Repository: "lagaffe",
155+
URL: "https://service/documentation",
156+
HeadBranch: "press",
157+
BaseBranch: "main",
158+
Sender: "owner",
159+
EventType: "pull_request",
160+
TriggerTarget: "pull_request",
161+
PullRequestNumber: 666,
162+
},
163+
tektondir: "testdata/bad_yaml",
164+
expectedPostedComment: ".*There are some errors in your PipelineRun template.*line 2: did not find expected key",
165+
},
152166
{
153167
name: "pull request/unknown-remotetask-but-fail-on-matching",
154168
runevent: info.Event{
@@ -548,6 +562,19 @@ func TestRun(t *testing.T) {
548562
ghtesthelper.SetupGitTree(t, mux, tt.tektondir, &tt.runevent, false)
549563
}
550564

565+
mux.HandleFunc(fmt.Sprintf("/repos/%s/%s/issues/%d/comments", tt.runevent.Organization, tt.runevent.Repository, tt.runevent.PullRequestNumber),
566+
func(w http.ResponseWriter, req *http.Request) {
567+
if req.Method == http.MethodPost {
568+
_, _ = fmt.Fprintf(w, `{"id": %d}`, tt.runevent.PullRequestNumber)
569+
// read body and compare it
570+
body, _ := io.ReadAll(req.Body)
571+
expectedRegexp := regexp.MustCompile(tt.expectedPostedComment)
572+
assert.Assert(t, expectedRegexp.Match(body), "expected comment %s, got %s", tt.expectedPostedComment, string(body))
573+
return
574+
}
575+
_, _ = fmt.Fprint(w, `[]`)
576+
})
577+
551578
stdata, _ := testclient.SeedTestData(t, ctx, tdata)
552579
cs := &params.Run{
553580
Clients: clients.Clients{

pkg/provider/bitbucketcloud/bitbucket.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ func (v *Provider) Client() *bitbucket.Client {
4545
return v.bbClient
4646
}
4747

48+
func (v *Provider) CreateComment(_ context.Context, _ *info.Event, _, _ string) error {
49+
return nil
50+
}
51+
4852
// CheckPolicyAllowing TODO: Implement ME.
4953
func (v *Provider) CheckPolicyAllowing(_ context.Context, _ *info.Event, _ []string) (bool, string) {
5054
return false, ""

pkg/provider/bitbucketdatacenter/bitbucketdatacenter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ func (v *Provider) SetScmClient(client *scm.Client) {
7474
v.scmClient = client
7575
}
7676

77+
func (v *Provider) CreateComment(_ context.Context, _ *info.Event, _, _ string) error {
78+
return nil
79+
}
80+
7781
func (v *Provider) SetPacInfo(pacInfo *info.PacOpts) {
7882
v.pacInfo = pacInfo
7983
}

pkg/provider/gitea/gitea.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/http"
99
"path"
10+
"regexp"
1011
"strconv"
1112
"strings"
1213

@@ -73,6 +74,40 @@ func (v *Provider) SetGiteaClient(client *gitea.Client) {
7374
v.giteaClient = client
7475
}
7576

77+
func (v *Provider) CreateComment(_ context.Context, event *info.Event, commit, updateMarker string) error {
78+
if v.giteaClient == nil {
79+
return fmt.Errorf("no gitea client has been initialized")
80+
}
81+
82+
if event.PullRequestNumber == 0 {
83+
return fmt.Errorf("create comment only works on pull requests")
84+
}
85+
86+
// List comments of the PR
87+
if updateMarker != "" {
88+
comments, _, err := v.Client().ListIssueComments(event.Organization, event.Repository, int64(event.PullRequestNumber), gitea.ListIssueCommentOptions{})
89+
if err != nil {
90+
return err
91+
}
92+
93+
re := regexp.MustCompile(updateMarker)
94+
for _, comment := range comments {
95+
if re.MatchString(comment.Body) {
96+
_, _, err := v.Client().EditIssueComment(event.Organization, event.Repository, comment.ID, gitea.EditIssueCommentOption{
97+
Body: commit,
98+
})
99+
return err
100+
}
101+
}
102+
}
103+
104+
_, _, err := v.Client().CreateIssueComment(event.Organization, event.Repository, int64(event.PullRequestNumber), gitea.CreateIssueCommentOption{
105+
Body: commit,
106+
})
107+
108+
return err
109+
}
110+
76111
func (v *Provider) SetPacInfo(pacInfo *info.PacOpts) {
77112
v.pacInfo = pacInfo
78113
}

pkg/provider/gitea/gitea_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,101 @@ func TestGetTektonDir(t *testing.T) {
525525
})
526526
}
527527
}
528+
529+
func TestCreateComment(t *testing.T) {
530+
tests := []struct {
531+
name string
532+
event *info.Event
533+
commit string
534+
updateMarker string
535+
mockResponses map[string]func(rw http.ResponseWriter, _ *http.Request)
536+
wantErr string
537+
clientNil bool
538+
}{
539+
{
540+
name: "nil client error",
541+
clientNil: true,
542+
event: &info.Event{PullRequestNumber: 123},
543+
wantErr: "no gitea client has been initialized",
544+
},
545+
{
546+
name: "not a pull request error",
547+
event: &info.Event{PullRequestNumber: 0},
548+
wantErr: "create comment only works on pull requests",
549+
},
550+
{
551+
name: "create new comment",
552+
event: &info.Event{Organization: "org", Repository: "repo", PullRequestNumber: 123},
553+
commit: "New Comment",
554+
updateMarker: "",
555+
mockResponses: map[string]func(rw http.ResponseWriter, _ *http.Request){
556+
"/repos/org/repo/issues/123/comments": func(rw http.ResponseWriter, r *http.Request) {
557+
assert.Equal(t, r.Method, http.MethodPost)
558+
fmt.Fprint(rw, `{}`)
559+
},
560+
},
561+
},
562+
{
563+
name: "update existing comment",
564+
event: &info.Event{Organization: "org", Repository: "repo", PullRequestNumber: 123},
565+
commit: "Updated Comment",
566+
updateMarker: "MARKER",
567+
mockResponses: map[string]func(rw http.ResponseWriter, _ *http.Request){
568+
"/repos/org/repo/issues/123/comments": func(rw http.ResponseWriter, r *http.Request) {
569+
if r.Method == http.MethodGet {
570+
fmt.Fprint(rw, `[{"id": 555, "body": "MARKER"}]`)
571+
return
572+
}
573+
},
574+
"/repos/org/repo/issues/comments/555": func(rw http.ResponseWriter, r *http.Request) {
575+
assert.Equal(t, r.Method, "PATCH")
576+
rw.WriteHeader(http.StatusOK)
577+
fmt.Fprint(rw, `{}`)
578+
},
579+
},
580+
},
581+
{
582+
name: "no matching comment creates new",
583+
event: &info.Event{Organization: "org", Repository: "repo", PullRequestNumber: 123},
584+
commit: "New Comment",
585+
updateMarker: "MARKER",
586+
mockResponses: map[string]func(rw http.ResponseWriter, _ *http.Request){
587+
"/repos/org/repo/issues/123/comments": func(rw http.ResponseWriter, r *http.Request) {
588+
if r.Method == http.MethodGet {
589+
fmt.Fprint(rw, `[{"id": 555, "body": "NO_MATCH"}]`)
590+
return
591+
}
592+
assert.Equal(t, r.Method, http.MethodPost)
593+
rw.WriteHeader(http.StatusCreated)
594+
fmt.Fprint(rw, `{}`)
595+
},
596+
},
597+
},
598+
}
599+
600+
for _, tt := range tests {
601+
t.Run(tt.name, func(t *testing.T) {
602+
fakeclient, mux, teardown := tgitea.Setup(t)
603+
defer teardown()
604+
605+
if tt.clientNil {
606+
p := &Provider{}
607+
err := p.CreateComment(context.Background(), tt.event, tt.commit, tt.updateMarker)
608+
assert.ErrorContains(t, err, tt.wantErr)
609+
return
610+
}
611+
612+
for endpoint, handler := range tt.mockResponses {
613+
mux.HandleFunc(endpoint, handler)
614+
}
615+
616+
p := &Provider{giteaClient: fakeclient}
617+
err := p.CreateComment(context.Background(), tt.event, tt.commit, tt.updateMarker)
618+
if tt.wantErr != "" {
619+
assert.ErrorContains(t, err, tt.wantErr)
620+
} else {
621+
assert.NilError(t, err)
622+
}
623+
})
624+
}
625+
}

0 commit comments

Comments
 (0)