Skip to content

Commit f529b6d

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 5a974ee commit f529b6d

File tree

21 files changed

+630
-53
lines changed

21 files changed

+630
-53
lines changed

docs/content/docs/dev/_index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,14 @@ For example, to test and lint the go files:
179179
make test lint-go
180180
```
181181

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

184184
```shell
185185
make update-golden
186186
```
187187

188+
Head over to the [./test/README.md](./test/README.md) for more information on how to update the golden files on the E2E tests.
189+
188190
## Configuring the Pre Push Git checks
189191

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

docs/content/docs/guide/running.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,26 @@ PipelineRun, another user who does have the permissions can comment
6666
The `OWNERS` file follows a specific format similar to the Prow `OWNERS` file
6767
format (detailed at <https://www.kubernetes.dev/docs/guide/owners/>). We
6868
support a basic `OWNERS` configuration with `approvers` and `reviewers` lists,
69-
both of which have equal permissions for executing a `PipelineRun`.
69+
both of which have equal permissions for executing a `PipelineRun`.
7070

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

7575
Additionally, `OWNERS_ALIASES` is supported and allows mapping alias names to
76-
lists of usernames.
76+
lists of usernames.
7777

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

8282
For example, if your repository’s `main` or `master` branch contains the
83-
following `approvers` section:
83+
following `approvers` section:
8484

8585
```yaml
8686
approvers:
8787
- approved
88-
```
88+
```
8989

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

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

120143
### 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 {
@@ -170,12 +177,18 @@ func (p *PacRun) getPipelineRunsFromRepo(ctx context.Context, repo *v1alpha1.Rep
170177
provenance = repo.Spec.Settings.PipelineRunProvenance
171178
}
172179
rawTemplates, err := p.vcx.GetTektonDir(ctx, p.event, tektonDir, provenance)
173-
if err != nil && strings.Contains(err.Error(), "error unmarshalling yaml file") {
180+
if err != nil && p.event.TriggerTarget == triggertype.PullRequest && strings.Contains(err.Error(), "error unmarshalling yaml file") {
174181
// make the error a bit more friendly for users who don't know what marshalling or intricacies of the yaml parser works
175-
errmsg := err.Error()
176-
errmsg = strings.ReplaceAll(errmsg, " error converting YAML to JSON: yaml:", "")
177-
errmsg = strings.ReplaceAll(errmsg, "unmarshalling", "while parsing the")
178-
return nil, fmt.Errorf("%s", errmsg)
182+
// format is "error unmarshalling yaml file pr-bad-format.yaml: yaml: line 3: could not find expected ':'"
183+
// get the filename with a regexp
184+
reg := regexp.MustCompile(`error unmarshalling yaml file\s([^:]*):\s*(yaml:\s*)?(.*)`)
185+
matches := reg.FindStringSubmatch(err.Error())
186+
if len(matches) == 4 {
187+
p.reportValidationErrors(ctx, repo, map[string]string{matches[1]: matches[3]})
188+
return nil, nil
189+
}
190+
191+
return nil, err
179192
}
180193
if err != nil || rawTemplates == "" {
181194
msg := fmt.Sprintf("cannot locate templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
@@ -225,15 +238,12 @@ func (p *PacRun) getPipelineRunsFromRepo(ctx context.Context, repo *v1alpha1.Rep
225238
return nil, err
226239
}
227240

228-
if types.ValidationErrors != nil {
229-
for k, v := range types.ValidationErrors {
230-
kv := fmt.Sprintf("prun: %s tekton validation error: %s", k, v)
231-
p.eventEmitter.EmitMessage(repo, zap.ErrorLevel, "PipelineRunValidationErrors", kv)
232-
}
241+
if len(types.ValidationErrors) > 0 && p.event.TriggerTarget == triggertype.PullRequest {
242+
p.reportValidationErrors(ctx, repo, types.ValidationErrors)
233243
}
234244
pipelineRuns := types.PipelineRuns
235245
if len(pipelineRuns) == 0 {
236-
msg := fmt.Sprintf("cannot locate templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
246+
msg := fmt.Sprintf("cannot locate valid templates in %s/ directory for this repository in %s", tektonDir, p.event.HeadBranch)
237247
p.eventEmitter.EmitMessage(nil, zap.InfoLevel, "RepositoryCannotLocatePipelineRun", msg)
238248
return nil, nil
239249
}
@@ -430,3 +440,22 @@ func (p *PacRun) createNeutralStatus(ctx context.Context) error {
430440

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

pkg/pipelineascode/match_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ func TestGetPipelineRunsFromRepo(t *testing.T) {
167167
},
168168
tektondir: "testdata/invalid_tekton_yaml",
169169
event: pullRequestEvent,
170-
logSnippet: `prun: bad-tekton-yaml tekton validation error: json: cannot unmarshal object into Go struct field PipelineSpec.spec.pipelineSpec.tasks of type []v1beta1.PipelineTask`,
170+
logSnippet: `json: cannot unmarshal object into Go struct field PipelineSpec.spec.pipelineSpec.tasks of type []v1beta1.PipelineTask`,
171171
},
172172
{
173173
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
@@ -31,6 +31,10 @@ type Provider struct {
3131
provenance string
3232
}
3333

34+
func (v *Provider) CreateComment(_ context.Context, _ *info.Event, _, _ string) error {
35+
return nil
36+
}
37+
3438
// CheckPolicyAllowing TODO: Implement ME.
3539
func (v *Provider) CheckPolicyAllowing(_ context.Context, _ *info.Event, _ []string) (bool, string) {
3640
return false, ""

pkg/provider/bitbucketserver/bitbucketserver.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ type Provider struct {
4545
projectKey string
4646
}
4747

48+
func (v *Provider) CreateComment(_ context.Context, _ *info.Event, _, _ string) error {
49+
return nil
50+
}
51+
4852
func (v *Provider) SetPacInfo(pacInfo *info.PacOpts) {
4953
v.pacInfo = pacInfo
5054
}

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

@@ -55,6 +56,40 @@ type Provider struct {
5556
run *params.Run
5657
}
5758

59+
func (v *Provider) CreateComment(_ context.Context, event *info.Event, commit, updateMarker string) error {
60+
if v.Client == nil {
61+
return fmt.Errorf("no gitea client has been initialized")
62+
}
63+
64+
if event.PullRequestNumber == 0 {
65+
return fmt.Errorf("create comment only works on pull requests")
66+
}
67+
68+
// List comments of the PR
69+
if updateMarker != "" {
70+
comments, _, err := v.Client.ListIssueComments(event.Organization, event.Repository, int64(event.PullRequestNumber), gitea.ListIssueCommentOptions{})
71+
if err != nil {
72+
return err
73+
}
74+
75+
for _, comment := range comments {
76+
re := regexp.MustCompile(updateMarker)
77+
if re.MatchString(comment.Body) {
78+
_, _, err := v.Client.EditIssueComment(event.Organization, event.Repository, comment.ID, gitea.EditIssueCommentOption{
79+
Body: commit,
80+
})
81+
return err
82+
}
83+
}
84+
}
85+
86+
_, _, err := v.Client.CreateIssueComment(event.Organization, event.Repository, int64(event.PullRequestNumber), gitea.CreateIssueCommentOption{
87+
Body: commit,
88+
})
89+
90+
return err
91+
}
92+
5893
func (v *Provider) SetPacInfo(pacInfo *info.PacOpts) {
5994
v.pacInfo = pacInfo
6095
}

0 commit comments

Comments
 (0)