Skip to content

Commit 9c12c8c

Browse files
chmouelclaude
andcommitted
feat: Support arbitrary CEL expressions with cel: prefix
Introduced the `cel:` prefix to allow evaluating complex Common Expression Language (CEL) expressions within PipelineRun templates. Enabled access to the `body`, `headers`, `files`, or `pac` namespaces to facilitate advanced logic like ternary operations, safe field access with `has()`, or collection processing. Ensured expressions return an empty string upon evaluation or syntax errors to prevent pipeline disruptions. Added comprehensive documentation, unit tests, plus integration tests for various use cases. E2E Tests was added for Gitea, GitHub and GitLab to validate the feature. Jira: https://issues.redhat.com/browse/SRVKP-8619 Co-authored-by: Claude <[email protected]> Signed-off-by: Chmouel Boudjnah <[email protected]>
1 parent 5c06a0a commit 9c12c8c

12 files changed

+641
-4
lines changed

docs/content/docs/guide/authoringprs.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,77 @@ for example, this will show the GitHub event type for a GitHub event:
187187

188188
and then you can do the same conditional or access as described above for the `body` keyword.
189189

190+
## Using the cel: prefix for advanced CEL expressions
191+
192+
For more complex CEL expressions that go beyond simple property access, you can
193+
use the `cel:` prefix. This allows you to write arbitrary CEL expressions with
194+
access to all available data sources.
195+
196+
The `cel:` prefix provides access to:
197+
198+
- `body` - The full webhook payload
199+
- `headers` - HTTP request headers
200+
- `files` - Changed files information (`files.all`, `files.added`, `files.deleted`, `files.modified`, `files.renamed`)
201+
- `pac` - Standard PAC parameters (`pac.revision`, `pac.target_branch`, `pac.source_branch`, etc.)
202+
203+
### Examples
204+
205+
**Conditional values based on event action:**
206+
207+
```yaml
208+
params:
209+
- name: pr-status
210+
value: "{{ cel: body.action == \"opened\" ? \"new-pr\" : \"updated-pr\" }}"
211+
```
212+
213+
**Environment selection based on target branch:**
214+
215+
```yaml
216+
params:
217+
- name: environment
218+
value: "{{ cel: pac.target_branch == \"main\" ? \"production\" : \"staging\" }}"
219+
```
220+
221+
**Safe field access with has() function:**
222+
223+
Use the `has()` function to safely check if a field exists before accessing it:
224+
225+
```yaml
226+
params:
227+
- name: commit-type
228+
value: "{{ cel: has(body.head_commit) && body.head_commit.message.startsWith(\"Merge\") ? \"merge\" : \"regular\" }}"
229+
```
230+
231+
**Check if Go files were modified:**
232+
233+
```yaml
234+
params:
235+
- name: run-go-tests
236+
value: "{{ cel: files.all.exists(f, f.endsWith(\".go\")) ? \"true\" : \"false\" }}"
237+
```
238+
239+
**String concatenation:**
240+
241+
```yaml
242+
params:
243+
- name: greeting
244+
value: "{{ cel: \"Build for \" + pac.repo_name + \" on \" + pac.target_branch }}"
245+
```
246+
247+
**Count changed files:**
248+
249+
```yaml
250+
params:
251+
- name: file-count
252+
value: "{{ cel: files.all.size() }}"
253+
```
254+
255+
{{< hint info >}}
256+
If a `cel:` expression has a syntax error or fails to evaluate, it returns an
257+
empty string. This allows PipelineRuns to continue even if an optional dynamic
258+
value cannot be computed.
259+
{{< /hint >}}
260+
190261
## Using the temporary GitHub APP Token for GitHub API operations
191262

192263
You can use the temporary installation token that is generated by Pipelines as

pkg/templates/templating.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ var (
2727
// value.
2828
//
2929
// The function first checks if the key in the placeholder has a prefix of
30-
// "body", "headers", or "files". If it does and both `rawEvent` and `headers`
30+
// "body", "headers", "files", or "cel:". If it does and both `rawEvent` and `headers`
3131
// are not nil, it attempts to retrieve the value for the key using the
3232
// `cel.Value` function and returns the corresponding string
33-
// representation. If the key does not have any of the mentioned prefixes, the
33+
// representation. The "cel:" prefix allows evaluating arbitrary CEL expressions
34+
// with access to body, headers, files, and pac (standard PAC parameters) namespaces.
35+
// If the key does not have any of the mentioned prefixes, the
3436
// function checks if the key exists in the `dico` map. If it does, the
3537
// function replaces the placeholder with the corresponding value from the
3638
// `dico` map.
@@ -52,10 +54,18 @@ func ReplacePlaceHoldersVariables(template string, dico map[string]string, rawEv
5254
return keys.ParamsRe.ReplaceAllStringFunc(template, func(s string) string {
5355
parts := keys.ParamsRe.FindStringSubmatch(s)
5456
key := strings.TrimSpace(parts[1])
55-
if strings.HasPrefix(key, "body") || strings.HasPrefix(key, "headers") || strings.HasPrefix(key, "files") {
57+
58+
// Check for cel: prefix first - it allows arbitrary CEL expressions
59+
isCelExpr := strings.HasPrefix(key, "cel:")
60+
61+
if strings.HasPrefix(key, "body") || strings.HasPrefix(key, "headers") || strings.HasPrefix(key, "files") || isCelExpr {
5662
// Check specific requirements for each prefix
5763
canEvaluate := false
64+
celExpr := key
5865
switch {
66+
case isCelExpr:
67+
canEvaluate = true
68+
celExpr = strings.TrimSpace(strings.TrimPrefix(key, "cel:"))
5969
case strings.HasPrefix(key, "body") && rawEvent != nil:
6070
canEvaluate = true
6171
case strings.HasPrefix(key, "headers") && headers != nil:
@@ -70,8 +80,17 @@ func ReplacePlaceHoldersVariables(template string, dico map[string]string, rawEv
7080
for k, v := range headers {
7181
headerMap[k] = v[0]
7282
}
73-
val, err := cel.Value(key, rawEvent, headerMap, map[string]string{}, changedFiles)
83+
// For cel: prefix, pass dico as pacParams so pac.* variables are available
84+
pacParams := map[string]string{}
85+
if isCelExpr {
86+
pacParams = dico
87+
}
88+
val, err := cel.Value(celExpr, rawEvent, headerMap, pacParams, changedFiles)
7489
if err != nil {
90+
// For cel: prefix, return empty string on error
91+
if isCelExpr {
92+
return ""
93+
}
7594
return s
7695
}
7796
var raw any

pkg/templates/templating_test.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,255 @@ func TestReplacePlaceHoldersVariablesJSONOutput(t *testing.T) {
286286
}
287287
}
288288

289+
func TestReplacePlaceHoldersVariablesCelPrefix(t *testing.T) {
290+
tests := []struct {
291+
name string
292+
template string
293+
expected string
294+
dicto map[string]string
295+
headers http.Header
296+
changedFiles map[string]any
297+
rawEvent any
298+
}{
299+
{
300+
name: "cel: prefix with simple body access",
301+
template: `result: {{ cel: body.hello }}`,
302+
expected: `result: world`,
303+
dicto: map[string]string{},
304+
changedFiles: map[string]any{},
305+
headers: http.Header{},
306+
rawEvent: map[string]string{
307+
"hello": "world",
308+
},
309+
},
310+
{
311+
name: "cel: prefix with ternary expression",
312+
template: `status: {{ cel: body.action == "opened" ? "new-pr" : "updated-pr" }}`,
313+
expected: `status: new-pr`,
314+
dicto: map[string]string{},
315+
changedFiles: map[string]any{},
316+
headers: http.Header{},
317+
rawEvent: map[string]any{
318+
"action": "opened",
319+
},
320+
},
321+
{
322+
name: "cel: prefix with ternary expression - else branch",
323+
template: `status: {{ cel: body.action == "opened" ? "new-pr" : "updated-pr" }}`,
324+
expected: `status: updated-pr`,
325+
dicto: map[string]string{},
326+
changedFiles: map[string]any{},
327+
headers: http.Header{},
328+
rawEvent: map[string]any{
329+
"action": "synchronize",
330+
},
331+
},
332+
{
333+
name: "cel: prefix with pac namespace access",
334+
template: `branch: {{ cel: pac.target_branch }}`,
335+
expected: `branch: main`,
336+
dicto: map[string]string{
337+
"target_branch": "main",
338+
},
339+
changedFiles: map[string]any{},
340+
headers: http.Header{},
341+
rawEvent: map[string]any{},
342+
},
343+
{
344+
name: "cel: prefix with pac namespace conditional",
345+
template: `env: {{ cel: pac.target_branch == "main" ? "production" : "staging" }}`,
346+
expected: `env: production`,
347+
dicto: map[string]string{
348+
"target_branch": "main",
349+
},
350+
changedFiles: map[string]any{},
351+
headers: http.Header{},
352+
rawEvent: map[string]any{},
353+
},
354+
{
355+
name: "cel: prefix with pac namespace - staging branch",
356+
template: `env: {{ cel: pac.target_branch == "main" ? "production" : "staging" }}`,
357+
expected: `env: staging`,
358+
dicto: map[string]string{
359+
"target_branch": "develop",
360+
},
361+
changedFiles: map[string]any{},
362+
headers: http.Header{},
363+
rawEvent: map[string]any{},
364+
},
365+
{
366+
name: "cel: prefix with has() function",
367+
template: `has_field: {{ cel: has(body.optional_field) ? body.optional_field : "default" }}`,
368+
expected: `has_field: custom_value`,
369+
dicto: map[string]string{},
370+
changedFiles: map[string]any{},
371+
headers: http.Header{},
372+
rawEvent: map[string]any{
373+
"optional_field": "custom_value",
374+
},
375+
},
376+
{
377+
name: "cel: prefix with has() function - field missing",
378+
template: `has_field: {{ cel: has(body.optional_field) ? body.optional_field : "default" }}`,
379+
expected: `has_field: default`,
380+
dicto: map[string]string{},
381+
changedFiles: map[string]any{},
382+
headers: http.Header{},
383+
rawEvent: map[string]any{},
384+
},
385+
{
386+
name: "cel: prefix with files access",
387+
template: `go_files: {{ cel: files.all.exists(f, f.endsWith(".go")) ? "yes" : "no" }}`,
388+
expected: `go_files: yes`,
389+
dicto: map[string]string{},
390+
changedFiles: map[string]any{
391+
"all": []string{"main.go", "README.md"},
392+
},
393+
headers: http.Header{},
394+
rawEvent: map[string]any{},
395+
},
396+
{
397+
name: "cel: prefix with files access - no go files",
398+
template: `go_files: {{ cel: files.all.exists(f, f.endsWith(".go")) ? "yes" : "no" }}`,
399+
expected: `go_files: no`,
400+
dicto: map[string]string{},
401+
changedFiles: map[string]any{
402+
"all": []string{"README.md", "config.yaml"},
403+
},
404+
headers: http.Header{},
405+
rawEvent: map[string]any{},
406+
},
407+
{
408+
name: "cel: prefix with headers access",
409+
template: `event: {{ cel: headers["X-GitHub-Event"] }}`,
410+
expected: `event: push`,
411+
dicto: map[string]string{},
412+
changedFiles: map[string]any{},
413+
headers: http.Header{
414+
"X-GitHub-Event": []string{"push"},
415+
},
416+
rawEvent: map[string]any{},
417+
},
418+
{
419+
name: "cel: prefix with boolean result",
420+
template: `is_draft: {{ cel: body.draft == true }}`,
421+
expected: `is_draft: false`,
422+
dicto: map[string]string{},
423+
changedFiles: map[string]any{},
424+
headers: http.Header{},
425+
rawEvent: map[string]any{
426+
"draft": false,
427+
},
428+
},
429+
{
430+
name: "cel: prefix with invalid expression returns empty string",
431+
template: `invalid: {{ cel: invalid.syntax[ }}`,
432+
expected: `invalid: `,
433+
dicto: map[string]string{},
434+
changedFiles: map[string]any{},
435+
headers: http.Header{},
436+
rawEvent: map[string]any{},
437+
},
438+
{
439+
name: "cel: prefix with evaluation error returns empty string",
440+
template: `error: {{ cel: body.nonexistent.deep.field }}`,
441+
expected: `error: `,
442+
dicto: map[string]string{},
443+
changedFiles: map[string]any{},
444+
headers: http.Header{},
445+
rawEvent: map[string]any{},
446+
},
447+
{
448+
name: "cel: prefix with extra whitespace",
449+
template: `result: {{ cel: body.hello }}`,
450+
expected: `result: world`,
451+
dicto: map[string]string{},
452+
changedFiles: map[string]any{},
453+
headers: http.Header{},
454+
rawEvent: map[string]string{
455+
"hello": "world",
456+
},
457+
},
458+
{
459+
name: "cel: prefix with string concatenation",
460+
template: `greeting: {{ cel: "Hello, " + body.name + "!" }}`,
461+
expected: `greeting: Hello, World!`,
462+
dicto: map[string]string{},
463+
changedFiles: map[string]any{},
464+
headers: http.Header{},
465+
rawEvent: map[string]any{
466+
"name": "World",
467+
},
468+
},
469+
{
470+
name: "cel: prefix with size function",
471+
template: `count: {{ cel: body.items.size() }}`,
472+
expected: `count: 3`,
473+
dicto: map[string]string{},
474+
changedFiles: map[string]any{},
475+
headers: http.Header{},
476+
rawEvent: map[string]any{
477+
"items": []string{"a", "b", "c"},
478+
},
479+
},
480+
{
481+
name: "cel: prefix with complex nested conditional (merge commit detection)",
482+
template: `commit_type: {{ cel: has(body.head_commit) && body.head_commit.message.startsWith("Merge") ? "merge" : "regular" }}`,
483+
expected: `commit_type: merge`,
484+
dicto: map[string]string{},
485+
changedFiles: map[string]any{},
486+
headers: http.Header{},
487+
rawEvent: map[string]any{
488+
"head_commit": map[string]any{
489+
"message": "Merge pull request #123",
490+
},
491+
},
492+
},
493+
{
494+
name: "cel: prefix with complex nested conditional - regular commit",
495+
template: `commit_type: {{ cel: has(body.head_commit) && body.head_commit.message.startsWith("Merge") ? "merge" : "regular" }}`,
496+
expected: `commit_type: regular`,
497+
dicto: map[string]string{},
498+
changedFiles: map[string]any{},
499+
headers: http.Header{},
500+
rawEvent: map[string]any{
501+
"head_commit": map[string]any{
502+
"message": "Fix bug in parser",
503+
},
504+
},
505+
},
506+
{
507+
name: "cel: prefix mixed with regular placeholders",
508+
template: `branch: {{ target_branch }}, check: {{ cel: pac.target_branch == "main" ? "prod" : "dev" }}`,
509+
expected: `branch: main, check: prod`,
510+
dicto: map[string]string{
511+
"target_branch": "main",
512+
},
513+
changedFiles: map[string]any{},
514+
headers: http.Header{},
515+
rawEvent: map[string]any{},
516+
},
517+
{
518+
name: "cel: prefix with nil rawEvent still works",
519+
template: `result: {{ cel: pac.revision }}`,
520+
expected: `result: abc123`,
521+
dicto: map[string]string{"revision": "abc123"},
522+
changedFiles: map[string]any{},
523+
headers: http.Header{},
524+
rawEvent: nil,
525+
},
526+
}
527+
528+
for _, tt := range tests {
529+
t.Run(tt.name, func(t *testing.T) {
530+
got := ReplacePlaceHoldersVariables(tt.template, tt.dicto, tt.rawEvent, tt.headers, tt.changedFiles)
531+
if d := cmp.Diff(got, tt.expected); d != "" {
532+
t.Fatalf("-got, +want: %v", d)
533+
}
534+
})
535+
}
536+
}
537+
289538
func TestReplacePlaceHoldersVariablesEdgeCases(t *testing.T) {
290539
tests := []struct {
291540
name string

0 commit comments

Comments
 (0)