Skip to content

Commit 3f47d5c

Browse files
authored
feat: add ability to take action on a MR depending on various setttings (#8)
* feat: add ability to take action on a MR depending on various setttings * feat: implement stalebot demo * fix: handle errors returned from gitlab * build: Bump Go to 1.22.3 * fix: undo mods to label system * fix: rename EvaluationResult back * docs: document merge_request.has_label * feat: add 'merge_request.modified_files_list(patterns...)' that return the list of files modified, matching the optional pattern(s) * docs: update examples to check the MR state * docs: update README with new 'merge_request.modified_files_list' func * docs: make README examples more readable
1 parent ab3edcc commit 3f47d5c

15 files changed

+347
-91
lines changed

.gitlab-ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
run::cli:
2-
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/golang:1.22.2
2+
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/golang:1.22.3
33
rules:
44
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
55
script:

.scm-engine.example.yml

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# See: https://getbootstrap.com/docs/5.3/customize/color/#all-colors
22

3+
actions:
4+
- name: Warn that the ticket if older than 30 days
5+
if: merge_request.state != "closed" && merge_request.time_since_last_commit > duration("30d") && not merge_request.has_label("do-not-close")
6+
then:
7+
- action: comment
8+
message: "Hey, this MR is old, we will close it in 15 days if no activity has happened. If you want to disable this behavior, add the label 'do-not-close' on the MR."
9+
10+
- name: Close ticket if older than 45 days
11+
if: merge_request.state != "closed" && merge_request.time_since_last_commit > duration("45d") && not merge_request.has_label("do-not-close")
12+
then:
13+
- action: close
14+
- action: comment
15+
message: "As promised, we're closing the MR due to inactivity, bye bye"
16+
17+
- name: Approve MR if the 'break-glass-approve' label is configured
18+
if: merge_request.state != "closed" && not merge_request.approved && merge_request.has_label("break-glass-approve")
19+
then:
20+
- action: approve
21+
- action: comment
22+
message: "Approving the MR since it has the 'break-glass-approve' label. Talk to ITGC about this!"
23+
324
label:
425
- name: lang/go
526
color: "$indigo"
@@ -77,8 +98,7 @@ label:
7798
description: "Modified this a service directory"
7899
color: "$pink"
79100
script: >
80-
map(merge_request.diff_stats, { .path })
81-
| filter({ hasPrefix(#, "internal/service/") })
101+
merge_request.modified_files_list("internal/service/")
82102
| map({ filepath_dir(#) })
83103
| map({ trimPrefix(#, "internal/") })
84104
| uniq()
@@ -92,8 +112,7 @@ label:
92112
description: "Modified this my-command command"
93113
color: "$purple"
94114
script: >
95-
map(merge_request.diff_stats, { .path })
96-
| filter({ hasPrefix(#, "internal/app/my-command/subcommands/") })
115+
merge_request.modified_files_list("internal/app/my-command/subcommands/")
97116
| map({ filepath_dir(#) })
98117
| map({ trimPrefix(#, "internal/app/my-command/subcommands/") })
99118
| map({ string("command/" + #) })

README.md

+41-11
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,17 @@ label:
187187
color: "$pink"
188188
# From this script, returning a list of labels
189189
script: >
190-
map(merge_request.diff_stats, { .path }) // Generate a list of all file paths that was changed in the Merge Request
191-
| filter({ hasPrefix(#, "pkg/service/") }) // Remove all paths that doesn't start with "pkg/service/"
192-
| map({ filepath_dir(#) }) // Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
193-
| map({ trimPrefix(#, "pkg/") }) // Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
194-
| uniq() // Remove duplicate values from the output
190+
// Generate a list of all file paths that was changed in the Merge Request inside pkg/service/
191+
merge_request.modified_files_list("pkg/service/")
192+
193+
// Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
194+
| map({ filepath_dir(#) })
195+
196+
// Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
197+
| map({ trimPrefix(#, "pkg/") })
198+
199+
// Remove duplicate values from the output
200+
| uniq()
195201
```
196202

197203
### `label` (list)
@@ -301,11 +307,17 @@ label:
301307
description: "Modified this service directory"
302308
color: "$pink"
303309
script: >
304-
map(merge_request.diff_stats, { .path }) // Generate a list of all file paths that was changed in the Merge Request
305-
| filter({ hasPrefix(#, "pkg/service/") }) // Remove all paths that doesn't start with "pkg/service/"
306-
| map({ filepath_dir(#) }) // Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
307-
| map({ trimPrefix(#, "pkg/") }) // Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
308-
| uniq() // Remove duplicate values from the output
310+
// Generate a list of all file paths that was changed in the Merge Request inside pkg/service/
311+
merge_request.modified_files_list("pkg/service/")
312+
313+
// Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
314+
| map({ filepath_dir(#) })
315+
316+
// Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
317+
| map({ trimPrefix(#, "pkg/") })
318+
319+
// Remove duplicate values from the output
320+
| uniq()
309321
```
310322

311323
#### `label.color` (required)
@@ -468,7 +480,25 @@ Returns wether any of the provided files patterns have been modified in the Merg
468480
The file patterns use the [`.gitignore` format](https://git-scm.com/docs/gitignore#_pattern_format).
469481

470482
```expr
471-
merge_request.modified_files("*.go", "docs/")
483+
merge_request.modified_files("*.go", "docs/") == true
484+
```
485+
486+
#### `merge_request.modified_files_list`
487+
488+
Returns an array of files matching the provided (optional) pattern thas has been modified in the Merge Request.
489+
490+
The file patterns use the [`.gitignore` format](https://git-scm.com/docs/gitignore#_pattern_format).
491+
492+
```expr
493+
merge_request.modified_files_list("*.go", "docs/") == ["example/file.go", "docs/index.md"]
494+
```
495+
496+
#### `merge_request.has_label`
497+
498+
Returns wether any of the provided label exist on the Merge Request.
499+
500+
```expr
501+
merge_request.has_label("my-label-name")
472502
```
473503

474504
#### `duration`

cmd/shared.go

+41-25
Original file line numberDiff line numberDiff line change
@@ -36,59 +36,75 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr st
3636

3737
fmt.Println("Evaluating context")
3838

39-
matches, err := cfg.Evaluate(evalContext)
39+
labels, actions, err := cfg.Evaluate(evalContext)
4040
if err != nil {
4141
return err
4242
}
4343

44-
// spew.Dump(matches)
45-
46-
// for _, label := range matches {
47-
// fmt.Println(label.Name, label.Matched, label.Color)
48-
// }
49-
5044
fmt.Println("Sync labels")
5145

52-
if err := sync(ctx, client, remoteLabels, matches); err != nil {
53-
return err
54-
}
55-
56-
fmt.Println("Done!")
57-
58-
fmt.Println("Updating MR")
59-
60-
if err := apply(ctx, client, matches); err != nil {
46+
if err := syncLabels(ctx, client, remoteLabels, labels); err != nil {
6147
return err
6248
}
6349

6450
fmt.Println("Done!")
6551

66-
return nil
67-
}
68-
69-
func apply(ctx context.Context, client scm.Client, remoteLabels []scm.EvaluationResult) error {
7052
var (
7153
add scm.LabelOptions
7254
remove scm.LabelOptions
7355
)
7456

75-
for _, e := range remoteLabels {
57+
for _, e := range labels {
7658
if e.Matched {
7759
add = append(add, e.Name)
7860
} else {
7961
remove = append(remove, e.Name)
8062
}
8163
}
8264

83-
_, err := client.MergeRequests().Update(ctx, &scm.UpdateMergeRequestOptions{
65+
update := &scm.UpdateMergeRequestOptions{
8466
AddLabels: &add,
8567
RemoveLabels: &remove,
86-
})
68+
}
69+
70+
fmt.Println("Applying actions")
71+
72+
if err := runActions(ctx, client, update, actions); err != nil {
73+
return err
74+
}
75+
76+
fmt.Println("Done!")
77+
78+
fmt.Println("Updating MR")
79+
80+
if err := updateMergeRequest(ctx, client, update); err != nil {
81+
return err
82+
}
83+
84+
fmt.Println("Done!")
85+
86+
return nil
87+
}
88+
89+
func updateMergeRequest(ctx context.Context, client scm.Client, update *scm.UpdateMergeRequestOptions) error {
90+
_, err := client.MergeRequests().Update(ctx, update)
8791

8892
return err
8993
}
9094

91-
func sync(ctx context.Context, client scm.Client, remote []*scm.Label, required []scm.EvaluationResult) error {
95+
func runActions(ctx context.Context, client scm.Client, update *scm.UpdateMergeRequestOptions, actions []config.Action) error {
96+
for _, action := range actions {
97+
for _, task := range action.Then {
98+
if err := client.ApplyStep(ctx, update, task); err != nil {
99+
return err
100+
}
101+
}
102+
}
103+
104+
return nil
105+
}
106+
107+
func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, required []scm.EvaluationResult) error {
92108
fmt.Println("Going to sync", len(required), "required labels")
93109

94110
remoteLabels := map[string]*scm.Label{}
@@ -131,7 +147,7 @@ func sync(ctx context.Context, client scm.Client, remote []*scm.Label, required
131147
continue
132148
}
133149

134-
if label.EqualLabel(e) {
150+
if label.IsEqual(e) {
135151
continue
136152
}
137153

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/jippi/scm-engine
22

3-
go 1.22.2
3+
go 1.22.3
44

55
require (
66
github.com/99designs/gqlgen v0.17.46

pkg/config/action.go

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package config
2+
3+
import (
4+
"github.com/expr-lang/expr"
5+
"github.com/expr-lang/expr/vm"
6+
"github.com/jippi/scm-engine/pkg/scm"
7+
"github.com/jippi/scm-engine/pkg/stdlib"
8+
)
9+
10+
type Actions []Action
11+
12+
func (actions Actions) Evaluate(evalContext scm.EvalContext) ([]Action, error) {
13+
results := []Action{}
14+
15+
// Evaluate actions
16+
for _, action := range actions {
17+
ok, err := action.Evaluate(evalContext)
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
if !ok {
23+
continue
24+
}
25+
26+
results = append(results, action)
27+
}
28+
29+
return results, nil
30+
}
31+
32+
type ActionStep = scm.EvaluationActionStep
33+
34+
type Action scm.EvaluationActionResult
35+
36+
func (p *Action) Evaluate(evalContext scm.EvalContext) (bool, error) {
37+
program, err := p.initialize(evalContext)
38+
if err != nil {
39+
return false, err
40+
}
41+
42+
// Run the compiled expr-lang script
43+
return runAndCheckBool(program, evalContext)
44+
}
45+
46+
func (p *Action) initialize(evalContext scm.EvalContext) (*vm.Program, error) {
47+
opts := []expr.Option{}
48+
opts = append(opts, expr.AsBool())
49+
opts = append(opts, expr.Env(evalContext))
50+
opts = append(opts, stdlib.FunctionRenamer)
51+
opts = append(opts, stdlib.Functions...)
52+
53+
return expr.Compile(p.If, opts...)
54+
}

pkg/config/config.go

+9-23
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,24 @@
11
package config
22

33
import (
4-
"errors"
5-
"fmt"
6-
74
"github.com/jippi/scm-engine/pkg/scm"
85
)
96

107
type Config struct {
11-
Labels Labels `yaml:"label"`
8+
Labels Labels `yaml:"label"`
9+
Actions Actions `yaml:"actions"`
1210
}
1311

14-
func (c Config) Evaluate(e scm.EvalContext) ([]scm.EvaluationResult, error) {
15-
results, err := c.Labels.Evaluate(e)
12+
func (c Config) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, []Action, error) {
13+
labels, err := c.Labels.Evaluate(evalContext)
1614
if err != nil {
17-
return nil, err
15+
return nil, nil, err
1816
}
1917

20-
// Sanity/validation checks
21-
seen := map[string]bool{}
22-
23-
for _, result := range results {
24-
// Check labels has a proper name
25-
if len(result.Name) == 0 {
26-
return nil, errors.New("A label was generated with empty name, please check your configuration.")
27-
}
28-
29-
// Check uniqueness of labels
30-
if _, ok := seen[result.Name]; ok {
31-
return nil, fmt.Errorf("The label %q was generated multiple times, please check your configuration. Hint: If you use [compute] label type, you can use the 'uniq()' function (example: '| uniq()')", result.Name)
32-
}
33-
34-
seen[result.Name] = true
18+
actions, err := c.Actions.Evaluate(evalContext)
19+
if err != nil {
20+
return nil, nil, err
3521
}
3622

37-
return results, nil
23+
return labels, actions, nil
3824
}

pkg/config/label.go

+18
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Labels []*Label
2626
func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) {
2727
var results []scm.EvaluationResult
2828

29+
// Evaluate labels
2930
for _, label := range labels {
3031
evaluationResult, err := label.Evaluate(evalContext)
3132
if err != nil {
@@ -39,6 +40,23 @@ func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResu
3940
results = append(results, evaluationResult...)
4041
}
4142

43+
// Sanity/validation checks
44+
seen := map[string]bool{}
45+
46+
for _, result := range results {
47+
// Check labels has a proper name
48+
if len(result.Name) == 0 {
49+
return nil, errors.New("A label was generated with empty name, please check your configuration.")
50+
}
51+
52+
// Check uniqueness of labels
53+
if _, ok := seen[result.Name]; ok {
54+
return nil, fmt.Errorf("The label %q was generated multiple times, please check your configuration. Hint: If you use [compute] label type, you can use the 'uniq()' function (example: '| uniq()')", result.Name)
55+
}
56+
57+
seen[result.Name] = true
58+
}
59+
4260
return results, nil
4361
}
4462

0 commit comments

Comments
 (0)