Skip to content

Commit a534cad

Browse files
committed
feat: add 'update_description' action
1 parent 88f7f67 commit a534cad

11 files changed

+138
-20
lines changed

cmd/shared.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, event
9595

9696
slogctx.Info(ctx, "Applying actions")
9797

98-
if err := runActions(ctx, client, update, actions); err != nil {
98+
if err := runActions(ctx, evalContext, client, update, actions); err != nil {
9999
return err
100100
}
101101

@@ -120,10 +120,10 @@ func updateMergeRequest(ctx context.Context, client scm.Client, update *scm.Upda
120120
return err
121121
}
122122

123-
func runActions(ctx context.Context, client scm.Client, update *scm.UpdateMergeRequestOptions, actions []config.Action) error {
123+
func runActions(ctx context.Context, evalContext scm.EvalContext, client scm.Client, update *scm.UpdateMergeRequestOptions, actions []config.Action) error {
124124
for _, action := range actions {
125125
for _, task := range action.Then {
126-
if err := client.ApplyStep(ctx, update, task); err != nil {
126+
if err := client.ApplyStep(ctx, evalContext, update, task); err != nil {
127127
return err
128128
}
129129
}

docs/configuration.md

+17-4
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ The list of operations to take if the [`#!css action.if`](#actions.if) returned
3838

3939
This key controls what kind of action that should be taken.
4040

41+
- `#!yaml approve` to approve the Merge Request.
4142
- `#!yaml close` to close the Merge Request.
42-
- `#!yaml reopen` to reopen the Merge Request.
43+
- `#!yaml comment` to add a comment to the Merge Request
4344
- `#!yaml lock_discussion` to prevent further discussions on the Merge Request.
44-
- `#!yaml unlock_discussion` to allow discussions on the Merge Request.
45-
- `#!yaml approve` to approve the Merge Request.
45+
- `#!yaml reopen` to reopen the Merge Request.
4646
- `#!yaml unapprove` to approve the Merge Request.
47-
- `#!yaml comment` to add a comment to the Merge Request
47+
- `#!yaml unlock_discussion` to allow discussions on the Merge Request.
48+
- `#!yaml update_description` to update the Merge Request description
4849

4950
*Additional fields:*
5051

@@ -78,6 +79,18 @@ This key controls what kind of action that should be taken.
7879
label: example
7980
```
8081

82+
- `#!yaml update_description` updates the Merge Request Description
83+
84+
*Additional fields:*
85+
86+
- (required) `#!css replace` A list of key/value pairs to replace in the description. The `key` is the raw string to replace in the Merge Request description. The `value` is an Expr Lang expression returning a `string` that `key` will be replaced with - all Script Attributes and Script Functions are available within the script.
87+
88+
```{.yaml title="update_description example"}
89+
- action: update_description
90+
replace:
91+
"${{CI_MERGE_REQUEST_IID}}": "merge_request.iid"
92+
```
93+
8194
## `label[]` {#label data-toc-label="label"}
8295

8396
!!! question "What are labels?"

docs/gitlab/script-functions.md

+16
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ merge_request.state_is("merged")
2020
merge_request.state_is("opened", "locked")
2121
```
2222

23+
### `merge_request.state_is_not(string...) -> boolean` {: #merge_request.state_is data-toc-label="state_is"}
24+
25+
Check if the `merge_request` state is NOT any of the provided states
26+
27+
**Valid options**:
28+
29+
- `closed` - In closed state
30+
- `locked` - Discussion has been locked
31+
- `merged` - Merge request has been merged
32+
- `opened` - Opened merge request
33+
34+
```css
35+
merge_request.state_is_not("merged")
36+
merge_request.state_is_not("opened", "locked")
37+
```
38+
2339
### `merge_request.has_user_activity_within(duration|string...) -> boolean` {: #merge_request.has_user_activity_within data-toc-label="has_user_activity_within"}
2440

2541
!!! info "This function *EXCLUDE* changes made by `scm-engine` and other bots, use [`merge_request.has_no_activity_within`](#merge_request.has_no_activity_within) if you want to include those"

pkg/scm/github/client_actioner.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
slogctx "github.com/veqryn/slog-context"
1313
)
1414

15-
func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error {
15+
func (c *Client) ApplyStep(ctx context.Context, evalContext scm.EvalContext, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error {
1616
owner, repo := ownerAndRepo(ctx)
1717

1818
action, ok := step["action"]

pkg/scm/github/context.go

+4
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,7 @@ func (c *Context) IsValid() bool {
8484
func (c *Context) SetWebhookEvent(in any) {
8585
c.WebhookEvent = in
8686
}
87+
88+
func (c *Context) GetDescription() string {
89+
return c.PullRequest.Body
90+
}

pkg/scm/gitlab/client.go

+1-6
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,7 @@ func (client *Client) MergeRequests() scm.MergeRequestClient {
5656

5757
// EvalContext creates a new evaluation context for GitLab specific usage
5858
func (client *Client) EvalContext(ctx context.Context) (scm.EvalContext, error) {
59-
res, err := NewContext(ctx, graphqlBaseURL(client.wrapped.BaseURL()), state.Token(ctx))
60-
if err != nil {
61-
return nil, err
62-
}
63-
64-
return res, nil
59+
return NewContext(ctx, graphqlBaseURL(client.wrapped.BaseURL()), state.Token(ctx))
6560
}
6661

6762
// Start pipeline

pkg/scm/gitlab/client_actioner.go

+68-1
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"reflect"
9+
"strings"
810

11+
"github.com/expr-lang/expr"
912
"github.com/jippi/scm-engine/pkg/scm"
1013
"github.com/jippi/scm-engine/pkg/state"
14+
"github.com/jippi/scm-engine/pkg/stdlib"
1115
slogctx "github.com/veqryn/slog-context"
1216
"github.com/xanzy/go-gitlab"
1317
)
1418

15-
func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error {
19+
func (c *Client) ApplyStep(ctx context.Context, evalContext scm.EvalContext, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error {
1620
action, ok := step["action"]
1721
if !ok {
1822
return errors.New("step is missing an 'action' key")
@@ -24,6 +28,69 @@ func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOp
2428
}
2529

2630
switch actionString {
31+
case "update_description":
32+
// Use the raw MR description
33+
body := evalContext.GetDescription()
34+
35+
// Unless something else already updated the description in the Update struct
36+
if update.Description != nil {
37+
body = *update.Description
38+
}
39+
40+
replacements, ok := step["replace"]
41+
if !ok {
42+
return errors.New("step field 'replace' is required, but missing")
43+
}
44+
45+
replacementSlice, ok := replacements.(map[string]any)
46+
if !ok {
47+
return fmt.Errorf(`step field 'replace' must be a dictionary with string key and string values ("key": "value"), got: %T`, replacements)
48+
}
49+
50+
replacedAnything := false
51+
52+
for key, script := range replacementSlice {
53+
// If the replacement key do not exist; we can skip the replacement logic entirely!
54+
if !strings.Contains(body, key) {
55+
continue
56+
}
57+
58+
replacedAnything = true
59+
60+
// Build the ExprLang VM program
61+
// TODO(jippi): make this something generic/shared somewhere more central so we keep settings in sync
62+
opts := []expr.Option{}
63+
opts = append(opts, expr.AsKind(reflect.TypeFor[string]().Kind()))
64+
opts = append(opts, expr.Env(evalContext))
65+
opts = append(opts, stdlib.FunctionRenamer)
66+
opts = append(opts, stdlib.Functions...)
67+
68+
program, err := expr.Compile(fmt.Sprintf("%s", script), opts...)
69+
if err != nil {
70+
return fmt.Errorf("could not evaluate value for 'replace' key '%s': %w", key, err)
71+
}
72+
73+
output, err := expr.Run(program, evalContext)
74+
if err != nil {
75+
return err
76+
}
77+
78+
switch val := output.(type) {
79+
case string:
80+
body = strings.ReplaceAll(body, key, val)
81+
82+
default:
83+
return fmt.Errorf("'replace' value for key '%s' did not return a string: %w", key, err)
84+
}
85+
}
86+
87+
// Don't update the body if there were no replacements
88+
if !replacedAnything {
89+
return nil
90+
}
91+
92+
update.Description = &body
93+
2794
case "add_label":
2895
name, ok := step["name"]
2996
if !ok {

pkg/scm/gitlab/context.go

+8
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,11 @@ func (c *Context) IsValid() bool {
9393
func (c *Context) SetWebhookEvent(in any) {
9494
c.WebhookEvent = in
9595
}
96+
97+
func (c *Context) GetDescription() string {
98+
if c.MergeRequest.Description == nil {
99+
return ""
100+
}
101+
102+
return *c.MergeRequest.Description
103+
}

pkg/scm/gitlab/context_merge_request.go

+14
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ func (e ContextMergeRequest) StateIs(anyOf ...string) bool {
3636
return false
3737
}
3838

39+
func (e ContextMergeRequest) StateIsNot(anyOf ...string) bool {
40+
for _, state := range anyOf {
41+
if !MergeRequestState(state).IsValid() {
42+
panic(fmt.Errorf("unknown state value: %q", state))
43+
}
44+
45+
if state == e.State {
46+
return false
47+
}
48+
}
49+
50+
return true
51+
}
52+
3953
// has_no_activity_within
4054
func (e ContextMergeRequest) HasNoActivityWithin(input any) bool {
4155
return !e.HasAnyActivityWithin(input)

pkg/scm/interfaces.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type Client interface {
99
Labels() LabelClient
1010
MergeRequests() MergeRequestClient
1111
EvalContext(ctx context.Context) (EvalContext, error)
12-
ApplyStep(ctx context.Context, update *UpdateMergeRequestOptions, step EvaluationActionStep) error
12+
ApplyStep(ctx context.Context, evalContext EvalContext, update *UpdateMergeRequestOptions, step EvaluationActionStep) error
1313
Start(ctx context.Context) error
1414
Stop(ctx context.Context, err error) error
1515
}
@@ -29,6 +29,7 @@ type MergeRequestClient interface {
2929
type EvalContext interface {
3030
IsValid() bool
3131
SetWebhookEvent(in any)
32+
GetDescription() string
3233
}
3334

3435
type EvalContextualizer struct{}

pkg/scm/types.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,12 @@ type EvaluationResult struct {
155155
Matched bool
156156
}
157157

158-
type EvaluationActionStep map[string]any
158+
type EvaluationActionStep = map[string]any
159159

160160
type EvaluationActionResult struct {
161-
Name string `yaml:"name"`
162-
If string `yaml:"if"`
163-
Then []EvaluationActionStep
161+
Name string `yaml:"name"`
162+
If string `yaml:"if"`
163+
Then []EvaluationActionStep `yaml:"then"`
164164
}
165165

166166
func (local EvaluationResult) IsEqual(ctx context.Context, remote *Label) bool {

0 commit comments

Comments
 (0)