Skip to content

Commit ad57afb

Browse files
committed
feat: add ability to take action on a MR depending on various setttings
1 parent ca1bb19 commit ad57afb

9 files changed

+226
-60
lines changed

.scm-engine.example.yml

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

3+
actions:
4+
- name: Close ticket if older than 45 days
5+
if: merge_request.time_since_last_commit > duration("45d")
6+
then:
7+
- action: close
8+
- action: comment
9+
message: "This Merge Request is older than 45 days and will be closed"
10+
311
label:
412
- name: lang/go
513
color: "$indigo"

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.EvaluationLabelResult) 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

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.EvaluationLabelResult, []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

+24-6
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ const (
2323

2424
type Labels []*Label
2525

26-
func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) {
27-
var results []scm.EvaluationResult
26+
func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, error) {
27+
var results []scm.EvaluationLabelResult
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

@@ -166,7 +184,7 @@ func (p *Label) ShouldSkip(evalContext scm.EvalContext) (bool, error) {
166184
return runAndCheckBool(p.skipIfCompiled, evalContext)
167185
}
168186

169-
func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) {
187+
func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, error) {
170188
if err := p.initialize(evalContext); err != nil {
171189
return nil, err
172190
}
@@ -182,7 +200,7 @@ func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, e
182200
return nil, err
183201
}
184202

185-
var result []scm.EvaluationResult
203+
var result []scm.EvaluationLabelResult
186204

187205
switch outputValue := output.(type) {
188206
case bool:
@@ -225,8 +243,8 @@ func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, e
225243
return result, nil
226244
}
227245

228-
func (p Label) resultForLabel(name string, matched bool) scm.EvaluationResult {
229-
return scm.EvaluationResult{
246+
func (p Label) resultForLabel(name string, matched bool) scm.EvaluationLabelResult {
247+
return scm.EvaluationLabelResult{
230248
Name: name,
231249
Matched: matched,
232250
Color: p.Color,

pkg/scm/gitlab/client_actioner.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package gitlab
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/jippi/scm-engine/pkg/scm"
9+
"github.com/jippi/scm-engine/pkg/state"
10+
"github.com/xanzy/go-gitlab"
11+
)
12+
13+
func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error {
14+
action, ok := step["action"]
15+
if !ok {
16+
return errors.New("step is missing an 'action' key")
17+
}
18+
19+
actionString, ok := action.(string)
20+
if !ok {
21+
return fmt.Errorf("step field 'action' must be of type string, got %T", action)
22+
}
23+
24+
switch actionString {
25+
case "close":
26+
update.StateEvent = gitlab.Ptr("close")
27+
28+
case "reopen":
29+
update.StateEvent = gitlab.Ptr("reopen")
30+
31+
case "discussion_locked":
32+
update.DiscussionLocked = gitlab.Ptr(true)
33+
34+
case "discussion_unlocked":
35+
update.DiscussionLocked = gitlab.Ptr(false)
36+
37+
case "comment":
38+
msg, ok := step["message"]
39+
if !ok {
40+
return errors.New("step field 'message' is required, but missing")
41+
}
42+
43+
msgString, ok := msg.(string)
44+
if !ok {
45+
return fmt.Errorf("step field 'message' must be a string, got %T", msg)
46+
}
47+
48+
if len(msgString) == 0 {
49+
return errors.New("step field 'message' must not be an empty string")
50+
}
51+
52+
c.wrapped.Notes.CreateMergeRequestNote(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.CreateMergeRequestNoteOptions{
53+
Body: gitlab.Ptr(msgString),
54+
})
55+
56+
default:
57+
return fmt.Errorf("GitLab client does not know how to apply action %q", step["action"])
58+
}
59+
60+
return nil
61+
}

pkg/scm/interfaces.go

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type Client interface {
88
Labels() LabelClient
99
MergeRequests() MergeRequestClient
1010
EvalContext(ctx context.Context) (EvalContext, error)
11+
ApplyStep(ctx context.Context, update *UpdateMergeRequestOptions, step EvaluationActionStep) error
1112
}
1213

1314
type LabelClient interface {

0 commit comments

Comments
 (0)