Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to take action on a MR depending on various setttings #8

Merged
merged 13 commits into from
May 8, 2024
Merged
2 changes: 1 addition & 1 deletion .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
run::cli:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/golang:1.22.2
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/golang:1.22.3
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
script:
Expand Down
27 changes: 23 additions & 4 deletions .scm-engine.example.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# See: https://getbootstrap.com/docs/5.3/customize/color/#all-colors

actions:
- name: Warn that the ticket if older than 30 days
if: merge_request.state != "closed" && merge_request.time_since_last_commit > duration("30d") && not merge_request.has_label("do-not-close")
then:
- action: comment
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."

- name: Close ticket if older than 45 days
if: merge_request.state != "closed" && merge_request.time_since_last_commit > duration("45d") && not merge_request.has_label("do-not-close")
then:
- action: close
- action: comment
message: "As promised, we're closing the MR due to inactivity, bye bye"

- name: Approve MR if the 'break-glass-approve' label is configured
if: merge_request.state != "closed" && not merge_request.approved && merge_request.has_label("break-glass-approve")
then:
- action: approve
- action: comment
message: "Approving the MR since it has the 'break-glass-approve' label. Talk to ITGC about this!"

label:
- name: lang/go
color: "$indigo"
Expand Down Expand Up @@ -77,8 +98,7 @@ label:
description: "Modified this a service directory"
color: "$pink"
script: >
map(merge_request.diff_stats, { .path })
| filter({ hasPrefix(#, "internal/service/") })
merge_request.modified_files_list("internal/service/")
| map({ filepath_dir(#) })
| map({ trimPrefix(#, "internal/") })
| uniq()
Expand All @@ -92,8 +112,7 @@ label:
description: "Modified this my-command command"
color: "$purple"
script: >
map(merge_request.diff_stats, { .path })
| filter({ hasPrefix(#, "internal/app/my-command/subcommands/") })
merge_request.modified_files_list("internal/app/my-command/subcommands/")
| map({ filepath_dir(#) })
| map({ trimPrefix(#, "internal/app/my-command/subcommands/") })
| map({ string("command/" + #) })
Expand Down
52 changes: 41 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,17 @@ label:
color: "$pink"
# From this script, returning a list of labels
script: >
map(merge_request.diff_stats, { .path }) // Generate a list of all file paths that was changed in the Merge Request
| filter({ hasPrefix(#, "pkg/service/") }) // Remove all paths that doesn't start with "pkg/service/"
| map({ filepath_dir(#) }) // Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
| map({ trimPrefix(#, "pkg/") }) // Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
| uniq() // Remove duplicate values from the output
// Generate a list of all file paths that was changed in the Merge Request inside pkg/service/
merge_request.modified_files_list("pkg/service/")

// Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
| map({ filepath_dir(#) })

// Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
| map({ trimPrefix(#, "pkg/") })

// Remove duplicate values from the output
| uniq()
```

### `label` (list)
Expand Down Expand Up @@ -301,11 +307,17 @@ label:
description: "Modified this service directory"
color: "$pink"
script: >
map(merge_request.diff_stats, { .path }) // Generate a list of all file paths that was changed in the Merge Request
| filter({ hasPrefix(#, "pkg/service/") }) // Remove all paths that doesn't start with "pkg/service/"
| map({ filepath_dir(#) }) // Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
| map({ trimPrefix(#, "pkg/") }) // Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
| uniq() // Remove duplicate values from the output
// Generate a list of all file paths that was changed in the Merge Request inside pkg/service/
merge_request.modified_files_list("pkg/service/")

// Remove the filename from the path "pkg/service/example/file.go" => "pkg/service/example"
| map({ filepath_dir(#) })

// Remove the prefix "pkg/" from the path "pkg/service/example" => "service/example"
| map({ trimPrefix(#, "pkg/") })

// Remove duplicate values from the output
| uniq()
```

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

```expr
merge_request.modified_files("*.go", "docs/")
merge_request.modified_files("*.go", "docs/") == true
```

#### `merge_request.modified_files_list`

Returns an array of files matching the provided (optional) pattern thas has been modified in the Merge Request.

The file patterns use the [`.gitignore` format](https://git-scm.com/docs/gitignore#_pattern_format).

```expr
merge_request.modified_files_list("*.go", "docs/") == ["example/file.go", "docs/index.md"]
```

#### `merge_request.has_label`

Returns wether any of the provided label exist on the Merge Request.

```expr
merge_request.has_label("my-label-name")
```

#### `duration`
Expand Down
66 changes: 41 additions & 25 deletions cmd/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,59 +36,75 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, mr st

fmt.Println("Evaluating context")

matches, err := cfg.Evaluate(evalContext)
labels, actions, err := cfg.Evaluate(evalContext)
if err != nil {
return err
}

// spew.Dump(matches)

// for _, label := range matches {
// fmt.Println(label.Name, label.Matched, label.Color)
// }

fmt.Println("Sync labels")

if err := sync(ctx, client, remoteLabels, matches); err != nil {
return err
}

fmt.Println("Done!")

fmt.Println("Updating MR")

if err := apply(ctx, client, matches); err != nil {
if err := syncLabels(ctx, client, remoteLabels, labels); err != nil {
return err
}

fmt.Println("Done!")

return nil
}

func apply(ctx context.Context, client scm.Client, remoteLabels []scm.EvaluationResult) error {
var (
add scm.LabelOptions
remove scm.LabelOptions
)

for _, e := range remoteLabels {
for _, e := range labels {
if e.Matched {
add = append(add, e.Name)
} else {
remove = append(remove, e.Name)
}
}

_, err := client.MergeRequests().Update(ctx, &scm.UpdateMergeRequestOptions{
update := &scm.UpdateMergeRequestOptions{
AddLabels: &add,
RemoveLabels: &remove,
})
}

fmt.Println("Applying actions")

if err := runActions(ctx, client, update, actions); err != nil {
return err
}

fmt.Println("Done!")

fmt.Println("Updating MR")

if err := updateMergeRequest(ctx, client, update); err != nil {
return err
}

fmt.Println("Done!")

return nil
}

func updateMergeRequest(ctx context.Context, client scm.Client, update *scm.UpdateMergeRequestOptions) error {
_, err := client.MergeRequests().Update(ctx, update)

return err
}

func sync(ctx context.Context, client scm.Client, remote []*scm.Label, required []scm.EvaluationResult) error {
func runActions(ctx context.Context, client scm.Client, update *scm.UpdateMergeRequestOptions, actions []config.Action) error {
for _, action := range actions {
for _, task := range action.Then {
if err := client.ApplyStep(ctx, update, task); err != nil {
return err
}
}
}

return nil
}

func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, required []scm.EvaluationResult) error {
fmt.Println("Going to sync", len(required), "required labels")

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

if label.EqualLabel(e) {
if label.IsEqual(e) {
continue
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/jippi/scm-engine

go 1.22.2
go 1.22.3

require (
github.com/99designs/gqlgen v0.17.46
Expand Down
54 changes: 54 additions & 0 deletions pkg/config/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package config

import (
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/jippi/scm-engine/pkg/scm"
"github.com/jippi/scm-engine/pkg/stdlib"
)

type Actions []Action

func (actions Actions) Evaluate(evalContext scm.EvalContext) ([]Action, error) {
results := []Action{}

// Evaluate actions
for _, action := range actions {
ok, err := action.Evaluate(evalContext)
if err != nil {
return nil, err
}

if !ok {
continue
}

results = append(results, action)
}

return results, nil
}

type ActionStep = scm.EvaluationActionStep

type Action scm.EvaluationActionResult

func (p *Action) Evaluate(evalContext scm.EvalContext) (bool, error) {
program, err := p.initialize(evalContext)
if err != nil {
return false, err
}

// Run the compiled expr-lang script
return runAndCheckBool(program, evalContext)
}

func (p *Action) initialize(evalContext scm.EvalContext) (*vm.Program, error) {
opts := []expr.Option{}
opts = append(opts, expr.AsBool())
opts = append(opts, expr.Env(evalContext))
opts = append(opts, stdlib.FunctionRenamer)
opts = append(opts, stdlib.Functions...)

return expr.Compile(p.If, opts...)
}
32 changes: 9 additions & 23 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -1,38 +1,24 @@
package config

import (
"errors"
"fmt"

"github.com/jippi/scm-engine/pkg/scm"
)

type Config struct {
Labels Labels `yaml:"label"`
Labels Labels `yaml:"label"`
Actions Actions `yaml:"actions"`
}

func (c Config) Evaluate(e scm.EvalContext) ([]scm.EvaluationResult, error) {
results, err := c.Labels.Evaluate(e)
func (c Config) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, []Action, error) {
labels, err := c.Labels.Evaluate(evalContext)
if err != nil {
return nil, err
return nil, nil, err
}

// Sanity/validation checks
seen := map[string]bool{}

for _, result := range results {
// Check labels has a proper name
if len(result.Name) == 0 {
return nil, errors.New("A label was generated with empty name, please check your configuration.")
}

// Check uniqueness of labels
if _, ok := seen[result.Name]; ok {
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)
}

seen[result.Name] = true
actions, err := c.Actions.Evaluate(evalContext)
if err != nil {
return nil, nil, err
}

return results, nil
return labels, actions, nil
}
18 changes: 18 additions & 0 deletions pkg/config/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Labels []*Label
func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) {
var results []scm.EvaluationResult

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

// Sanity/validation checks
seen := map[string]bool{}

for _, result := range results {
// Check labels has a proper name
if len(result.Name) == 0 {
return nil, errors.New("A label was generated with empty name, please check your configuration.")
}

// Check uniqueness of labels
if _, ok := seen[result.Name]; ok {
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)
}

seen[result.Name] = true
}

return results, nil
}

Expand Down
Loading
Loading