From ad57afb495d70417c8f8b781d7d42e97df73e44b Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Tue, 7 May 2024 23:41:23 +0200 Subject: [PATCH 01/11] feat: add ability to take action on a MR depending on various setttings --- .scm-engine.example.yml | 8 ++++ cmd/shared.go | 66 +++++++++++++++++++------------ pkg/config/action.go | 54 +++++++++++++++++++++++++ pkg/config/config.go | 32 +++++---------- pkg/config/label.go | 30 +++++++++++--- pkg/scm/gitlab/client_actioner.go | 61 ++++++++++++++++++++++++++++ pkg/scm/interfaces.go | 1 + pkg/scm/types.go | 18 ++++++--- pkg/state/context.go | 16 +++++++- 9 files changed, 226 insertions(+), 60 deletions(-) create mode 100644 pkg/config/action.go create mode 100644 pkg/scm/gitlab/client_actioner.go diff --git a/.scm-engine.example.yml b/.scm-engine.example.yml index 9fcf208..5c19db5 100644 --- a/.scm-engine.example.yml +++ b/.scm-engine.example.yml @@ -1,5 +1,13 @@ # See: https://getbootstrap.com/docs/5.3/customize/color/#all-colors +actions: + - name: Close ticket if older than 45 days + if: merge_request.time_since_last_commit > duration("45d") + then: + - action: close + - action: comment + message: "This Merge Request is older than 45 days and will be closed" + label: - name: lang/go color: "$indigo" diff --git a/cmd/shared.go b/cmd/shared.go index 1269d1a..f07cb8b 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -36,43 +36,25 @@ 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 { @@ -80,15 +62,49 @@ func apply(ctx context.Context, client scm.Client, remoteLabels []scm.Evaluation } } - _, 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.EvaluationLabelResult) error { fmt.Println("Going to sync", len(required), "required labels") remoteLabels := map[string]*scm.Label{} @@ -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 } diff --git a/pkg/config/action.go b/pkg/config/action.go new file mode 100644 index 0000000..bce0096 --- /dev/null +++ b/pkg/config/action.go @@ -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...) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 385696d..97ec580 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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.EvaluationLabelResult, []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 } diff --git a/pkg/config/label.go b/pkg/config/label.go index 0b27bcc..d133002 100644 --- a/pkg/config/label.go +++ b/pkg/config/label.go @@ -23,9 +23,10 @@ const ( type Labels []*Label -func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) { - var results []scm.EvaluationResult +func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, error) { + var results []scm.EvaluationLabelResult + // Evaluate labels for _, label := range labels { evaluationResult, err := label.Evaluate(evalContext) if err != nil { @@ -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 } @@ -166,7 +184,7 @@ func (p *Label) ShouldSkip(evalContext scm.EvalContext) (bool, error) { return runAndCheckBool(p.skipIfCompiled, evalContext) } -func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) { +func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, error) { if err := p.initialize(evalContext); err != nil { return nil, err } @@ -182,7 +200,7 @@ func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, e return nil, err } - var result []scm.EvaluationResult + var result []scm.EvaluationLabelResult switch outputValue := output.(type) { case bool: @@ -225,8 +243,8 @@ func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, e return result, nil } -func (p Label) resultForLabel(name string, matched bool) scm.EvaluationResult { - return scm.EvaluationResult{ +func (p Label) resultForLabel(name string, matched bool) scm.EvaluationLabelResult { + return scm.EvaluationLabelResult{ Name: name, Matched: matched, Color: p.Color, diff --git a/pkg/scm/gitlab/client_actioner.go b/pkg/scm/gitlab/client_actioner.go new file mode 100644 index 0000000..913625b --- /dev/null +++ b/pkg/scm/gitlab/client_actioner.go @@ -0,0 +1,61 @@ +package gitlab + +import ( + "context" + "errors" + "fmt" + + "github.com/jippi/scm-engine/pkg/scm" + "github.com/jippi/scm-engine/pkg/state" + "github.com/xanzy/go-gitlab" +) + +func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOptions, step scm.EvaluationActionStep) error { + action, ok := step["action"] + if !ok { + return errors.New("step is missing an 'action' key") + } + + actionString, ok := action.(string) + if !ok { + return fmt.Errorf("step field 'action' must be of type string, got %T", action) + } + + switch actionString { + case "close": + update.StateEvent = gitlab.Ptr("close") + + case "reopen": + update.StateEvent = gitlab.Ptr("reopen") + + case "discussion_locked": + update.DiscussionLocked = gitlab.Ptr(true) + + case "discussion_unlocked": + update.DiscussionLocked = gitlab.Ptr(false) + + case "comment": + msg, ok := step["message"] + if !ok { + return errors.New("step field 'message' is required, but missing") + } + + msgString, ok := msg.(string) + if !ok { + return fmt.Errorf("step field 'message' must be a string, got %T", msg) + } + + if len(msgString) == 0 { + return errors.New("step field 'message' must not be an empty string") + } + + c.wrapped.Notes.CreateMergeRequestNote(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.CreateMergeRequestNoteOptions{ + Body: gitlab.Ptr(msgString), + }) + + default: + return fmt.Errorf("GitLab client does not know how to apply action %q", step["action"]) + } + + return nil +} diff --git a/pkg/scm/interfaces.go b/pkg/scm/interfaces.go index a93aa69..9b580e2 100644 --- a/pkg/scm/interfaces.go +++ b/pkg/scm/interfaces.go @@ -8,6 +8,7 @@ type Client interface { Labels() LabelClient MergeRequests() MergeRequestClient EvalContext(ctx context.Context) (EvalContext, error) + ApplyStep(ctx context.Context, update *UpdateMergeRequestOptions, step EvaluationActionStep) error } type LabelClient interface { diff --git a/pkg/scm/types.go b/pkg/scm/types.go index 1154c0b..15040fa 100644 --- a/pkg/scm/types.go +++ b/pkg/scm/types.go @@ -72,6 +72,7 @@ type UpdateMergeRequestOptions struct { // GitLab API docs: https://docs.gitlab.com/ee/api/labels.html#list-labels type ListLabelsOptions struct { ListOptions + WithCounts *bool `json:"with_counts,omitempty" url:"with_counts,omitempty"` IncludeAncestorGroups *bool `json:"include_ancestor_groups,omitempty" url:"include_ancestor_groups,omitempty"` Search *string `json:"search,omitempty" url:"search,omitempty"` @@ -114,7 +115,7 @@ type Response struct { // LastLink string } -type EvaluationResult struct { +type EvaluationLabelResult struct { // Name of the label being generated. // // May only be used with [conditional] labelling type @@ -136,12 +137,19 @@ type EvaluationResult struct { // This controls if the label is prioritized (sorted first) in the list. Priority types.Value[int] - // - Matched bool - CreateInGroup string + // Wether the evaluation rule matched positive (add label) or negative (remove label) + Matched bool +} + +type EvaluationActionStep map[string]any + +type EvaluationActionResult struct { + Name string `yaml:"name"` + If string `yaml:"if"` + Then []EvaluationActionStep } -func (local EvaluationResult) EqualLabel(remote *Label) bool { +func (local EvaluationLabelResult) IsEqual(remote *Label) bool { if local.Name != remote.Name { return false } diff --git a/pkg/state/context.go b/pkg/state/context.go index 4bba020..58d6c48 100644 --- a/pkg/state/context.go +++ b/pkg/state/context.go @@ -1,6 +1,9 @@ package state -import "context" +import ( + "context" + "strconv" +) type contextKey uint @@ -29,6 +32,17 @@ func MergeRequestIDFromContext(ctx context.Context) string { return ctx.Value(mergeRequestID).(string) //nolint:forcetypeassert } +func MergeRequestIDFromContextInt(ctx context.Context) int { + val := MergeRequestIDFromContext(ctx) + + number, err := strconv.Atoi(val) + if err != nil { + panic(err) + } + + return number +} + func ContextWithMergeRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, mergeRequestID, id) } From b74cc88c766c41fff35511de6e73c96bd0746a1d Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:01:40 +0200 Subject: [PATCH 02/11] feat: implement stalebot demo --- .scm-engine.example.yml | 10 ++++++-- pkg/scm/gitlab/client_actioner.go | 6 +++++ pkg/scm/gitlab/context.model.go | 21 +++++++++++++++++ pkg/scm/gitlab/context_merge_request.go | 10 ++++++++ pkg/stdlib/renamer.go | 20 +++++++++++----- schema/gitlab.go | 31 +++++++++++++++++-------- schema/gitlab.gqlgen.yml | 3 +++ schema/gitlab.schema.graphqls | 6 +++-- 8 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 pkg/scm/gitlab/context.model.go diff --git a/.scm-engine.example.yml b/.scm-engine.example.yml index 5c19db5..b60a407 100644 --- a/.scm-engine.example.yml +++ b/.scm-engine.example.yml @@ -1,12 +1,18 @@ # 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.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.time_since_last_commit > duration("45d") + if: merge_request.time_since_last_commit > duration("45d") && not merge_request.has_label("do-not-close") then: - action: close - action: comment - message: "This Merge Request is older than 45 days and will be closed" + message: "As promised, we're closing the MR due to inactivity, bye bye" label: - name: lang/go diff --git a/pkg/scm/gitlab/client_actioner.go b/pkg/scm/gitlab/client_actioner.go index 913625b..8374048 100644 --- a/pkg/scm/gitlab/client_actioner.go +++ b/pkg/scm/gitlab/client_actioner.go @@ -34,6 +34,12 @@ func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOp case "discussion_unlocked": update.DiscussionLocked = gitlab.Ptr(false) + case "approve": + c.wrapped.MergeRequestApprovals.ApproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.ApproveMergeRequestOptions{}) + + case "unapprove": + c.wrapped.MergeRequestApprovals.UnapproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx)) + case "comment": msg, ok := step["message"] if !ok { diff --git a/pkg/scm/gitlab/context.model.go b/pkg/scm/gitlab/context.model.go new file mode 100644 index 0000000..be44956 --- /dev/null +++ b/pkg/scm/gitlab/context.model.go @@ -0,0 +1,21 @@ +package gitlab + +import ( + "encoding/json" + "io" +) + +type ContextLabels []ContextLabel + +func (l ContextLabels) MarshalGQL(writer io.Writer) { + data, err := json.Marshal(l) + if err != nil { + panic(err) + } + + writer.Write(data) +} + +func (l *ContextLabels) UnmarshalGQL(v interface{}) error { + return nil +} diff --git a/pkg/scm/gitlab/context_merge_request.go b/pkg/scm/gitlab/context_merge_request.go index aaa9dc2..9fc9463 100644 --- a/pkg/scm/gitlab/context_merge_request.go +++ b/pkg/scm/gitlab/context_merge_request.go @@ -7,6 +7,16 @@ import ( "strings" ) +func (e ContextMergeRequest) HasLabel(in string) bool { + for _, label := range e.Labels { + if label.Title == in { + return true + } + } + + return false +} + // Partially lifted from https://github.com/hmarr/codeowners/blob/main/match.go func (e ContextMergeRequest) ModifiedFiles(patterns ...string) bool { leftAnchoredLiteral := false diff --git a/pkg/stdlib/renamer.go b/pkg/stdlib/renamer.go index 781d10e..51d6985 100644 --- a/pkg/stdlib/renamer.go +++ b/pkg/stdlib/renamer.go @@ -3,6 +3,7 @@ package stdlib import ( "github.com/expr-lang/expr" "github.com/expr-lang/expr/ast" + "github.com/iancoleman/strcase" ) var FunctionRenamer = expr.Patch(functionRenamer{}) @@ -15,14 +16,21 @@ type functionRenamer struct{} func (x functionRenamer) Visit(node *ast.Node) { switch node := (*node).(type) { - case *ast.IdentifierNode: - if r, ok := renames[node.Value]; ok { - node.Value = r + case *ast.CallNode: + x.rename(&node.Callee) + } +} + +func (x functionRenamer) rename(node *ast.Node) { + switch node := (*node).(type) { + case *ast.MemberNode: + if !node.Method { + return } + x.rename(&node.Property) + case *ast.StringNode: - if r, ok := renames[node.Value]; ok { - node.Value = r - } + node.Value = strcase.ToCamel(node.Value) } } diff --git a/schema/gitlab.go b/schema/gitlab.go index c8c3de0..534cf4f 100644 --- a/schema/gitlab.go +++ b/schema/gitlab.go @@ -40,9 +40,9 @@ func main() { os.Exit(2) } - // Attaching the mutation function onto modelgen plugin + // Attaching the mutation function onto model-gen plugin p := modelgen.Plugin{ - FieldHook: constraintFieldHook, + FieldHook: fieldHook, MutateHook: mutateHook, } @@ -66,7 +66,10 @@ func main() { func nest(props []*Property) { for _, field := range props { + if field.IsCustomType { + fmt.Println("nesting", field.Name, "of type", field.Type) + for _, nested := range PropMap[field.Type].Attributes { field.AddAttribute(&Property{ Name: nested.Name, @@ -93,7 +96,7 @@ func getRootPath() string { } // Defining mutation function -func constraintFieldHook(td *ast.Definition, fd *ast.FieldDefinition, f *modelgen.Field) (*modelgen.Field, error) { +func fieldHook(td *ast.Definition, fd *ast.FieldDefinition, f *modelgen.Field) (*modelgen.Field, error) { // Call default hook to proceed standard directives like goField and goTag. // You can omit it, if you don't need. if f, err := modelgen.DefaultFieldMutateHook(td, fd, f); err != nil { @@ -145,7 +148,7 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { modelProperty := &Property{ Name: modelName, - Type: "model", + Type: modelName, Description: model.Description, } @@ -177,8 +180,6 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { Description: field.Description, } - fieldProperty.IsSlice = strings.HasPrefix(fieldType, "[]") - if strings.Contains(fieldType, "github.com/jippi/scm-engine") { fieldType = filepath.Base(fieldType) fieldType = strings.Split(fieldType, ".")[1] @@ -197,22 +198,32 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { } fieldProperty.Type = strings.TrimPrefix(fieldType, "*") + fieldProperty.IsSlice = strings.HasPrefix(fieldType, "[]") || fieldType == "labels" modelProperty.AddAttribute(fieldProperty) - } + + fmt.Println(" ", fieldProperty.Name, "of type", fieldProperty.Type) + } // end expr tag is set slices.SortFunc(modelProperty.Attributes, sortSlice) field.Tag = tags.String() - } + } // end fields loop if strings.HasSuffix(model.Name, "Node") || model.Name == "Query" { continue } + if modelProperty.Type == "label" { + modelProperty.Type = "labels" + modelProperty.IsSlice = true + } + + fmt.Println("Registering custom model", modelProperty.Name, "of type", modelProperty.Type) + Props = append(Props, modelProperty) - PropMap[modelProperty.Name] = modelProperty - } + PropMap[modelProperty.Type] = modelProperty + } // end model loop return b } diff --git a/schema/gitlab.gqlgen.yml b/schema/gitlab.gqlgen.yml index 9d8d106..cbcef41 100644 --- a/schema/gitlab.gqlgen.yml +++ b/schema/gitlab.gqlgen.yml @@ -88,3 +88,6 @@ models: Duration: model: - github.com/99designs/gqlgen/graphql.Duration + ContextLabels: + model: + - ../pkg/scm/gitlab.ContextLabels diff --git a/schema/gitlab.schema.graphqls b/schema/gitlab.schema.graphqls index 4042b8d..85a3400 100644 --- a/schema/gitlab.schema.graphqls +++ b/schema/gitlab.schema.graphqls @@ -21,6 +21,8 @@ scalar Time # Add time.Duration support scalar Duration +scalar ContextLabels + type Context { "The project the Merge Request belongs to" Project: ContextProject @graphql(key: "project(fullPath: $project_id)") @@ -66,7 +68,7 @@ type ContextProject { # Connections # - Labels: [ContextLabel!] @generated + Labels: ContextLabels @generated ResponseLabels: ContextLabelNode @internal @graphql(key: "labels(first: 200)") MergeRequest: ContextMergeRequest @internal @graphql(key: "mergeRequest(iid: $mr_id)") ResponseGroup: ContextGroup @internal @graphql(key: "group") @@ -178,7 +180,7 @@ type ContextMergeRequest { # DiffStats: [ContextDiffStat!] - Labels: [ContextLabel!] @generated + Labels: ContextLabels @generated ResponseLabels: ContextLabelNode @internal @graphql(key: "labels(first: 200)") ResponseFirstCommits: ContextCommitsNode @internal @graphql(key: "first_commit: commits(first:1)") ResponseLastCommits: ContextCommitsNode @internal @graphql(key: "last_commit: commits(last:1)") From 0066c033352668f1717512b3790e6481f41373e5 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:07:20 +0200 Subject: [PATCH 03/11] fix: handle errors returned from gitlab --- .scm-engine.example.yml | 7 +++++++ pkg/scm/gitlab/client_actioner.go | 12 +++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.scm-engine.example.yml b/.scm-engine.example.yml index b60a407..1936b36 100644 --- a/.scm-engine.example.yml +++ b/.scm-engine.example.yml @@ -14,6 +14,13 @@ actions: - 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: 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" diff --git a/pkg/scm/gitlab/client_actioner.go b/pkg/scm/gitlab/client_actioner.go index 8374048..8f8796c 100644 --- a/pkg/scm/gitlab/client_actioner.go +++ b/pkg/scm/gitlab/client_actioner.go @@ -35,10 +35,14 @@ func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOp update.DiscussionLocked = gitlab.Ptr(false) case "approve": - c.wrapped.MergeRequestApprovals.ApproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.ApproveMergeRequestOptions{}) + _, _, err := c.wrapped.MergeRequestApprovals.ApproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.ApproveMergeRequestOptions{}) + + return err case "unapprove": - c.wrapped.MergeRequestApprovals.UnapproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx)) + _, err := c.wrapped.MergeRequestApprovals.UnapproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx)) + + return err case "comment": msg, ok := step["message"] @@ -55,10 +59,12 @@ func (c *Client) ApplyStep(ctx context.Context, update *scm.UpdateMergeRequestOp return errors.New("step field 'message' must not be an empty string") } - c.wrapped.Notes.CreateMergeRequestNote(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.CreateMergeRequestNoteOptions{ + _, _, err := c.wrapped.Notes.CreateMergeRequestNote(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.CreateMergeRequestNoteOptions{ Body: gitlab.Ptr(msgString), }) + return err + default: return fmt.Errorf("GitLab client does not know how to apply action %q", step["action"]) } From 4462a562abd4899c423afb86e0b5098b98b6b1c6 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:10:59 +0200 Subject: [PATCH 04/11] build: Bump Go to 1.22.3 --- .gitlab-ci.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c7e3b21..b325f59 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/go.mod b/go.mod index 9b4b719..5ccd2f1 100644 --- a/go.mod +++ b/go.mod @@ -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.45 From 1c805dc936da6890bc6fad7ec9d8a533cdfb77a3 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:14:20 +0200 Subject: [PATCH 05/11] fix: undo mods to label system --- pkg/scm/gitlab/context.model.go | 21 --------------------- schema/gitlab.go | 13 +------------ schema/gitlab.gqlgen.yml | 3 --- schema/gitlab.schema.graphqls | 6 ++---- 4 files changed, 3 insertions(+), 40 deletions(-) delete mode 100644 pkg/scm/gitlab/context.model.go diff --git a/pkg/scm/gitlab/context.model.go b/pkg/scm/gitlab/context.model.go deleted file mode 100644 index be44956..0000000 --- a/pkg/scm/gitlab/context.model.go +++ /dev/null @@ -1,21 +0,0 @@ -package gitlab - -import ( - "encoding/json" - "io" -) - -type ContextLabels []ContextLabel - -func (l ContextLabels) MarshalGQL(writer io.Writer) { - data, err := json.Marshal(l) - if err != nil { - panic(err) - } - - writer.Write(data) -} - -func (l *ContextLabels) UnmarshalGQL(v interface{}) error { - return nil -} diff --git a/schema/gitlab.go b/schema/gitlab.go index 534cf4f..b0226e0 100644 --- a/schema/gitlab.go +++ b/schema/gitlab.go @@ -68,8 +68,6 @@ func nest(props []*Property) { for _, field := range props { if field.IsCustomType { - fmt.Println("nesting", field.Name, "of type", field.Type) - for _, nested := range PropMap[field.Type].Attributes { field.AddAttribute(&Property{ Name: nested.Name, @@ -198,11 +196,9 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { } fieldProperty.Type = strings.TrimPrefix(fieldType, "*") - fieldProperty.IsSlice = strings.HasPrefix(fieldType, "[]") || fieldType == "labels" + fieldProperty.IsSlice = strings.HasPrefix(fieldType, "[]") || fieldType == "label" modelProperty.AddAttribute(fieldProperty) - - fmt.Println(" ", fieldProperty.Name, "of type", fieldProperty.Type) } // end expr tag is set slices.SortFunc(modelProperty.Attributes, sortSlice) @@ -214,13 +210,6 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { continue } - if modelProperty.Type == "label" { - modelProperty.Type = "labels" - modelProperty.IsSlice = true - } - - fmt.Println("Registering custom model", modelProperty.Name, "of type", modelProperty.Type) - Props = append(Props, modelProperty) PropMap[modelProperty.Type] = modelProperty } // end model loop diff --git a/schema/gitlab.gqlgen.yml b/schema/gitlab.gqlgen.yml index cbcef41..9d8d106 100644 --- a/schema/gitlab.gqlgen.yml +++ b/schema/gitlab.gqlgen.yml @@ -88,6 +88,3 @@ models: Duration: model: - github.com/99designs/gqlgen/graphql.Duration - ContextLabels: - model: - - ../pkg/scm/gitlab.ContextLabels diff --git a/schema/gitlab.schema.graphqls b/schema/gitlab.schema.graphqls index 85a3400..4042b8d 100644 --- a/schema/gitlab.schema.graphqls +++ b/schema/gitlab.schema.graphqls @@ -21,8 +21,6 @@ scalar Time # Add time.Duration support scalar Duration -scalar ContextLabels - type Context { "The project the Merge Request belongs to" Project: ContextProject @graphql(key: "project(fullPath: $project_id)") @@ -68,7 +66,7 @@ type ContextProject { # Connections # - Labels: ContextLabels @generated + Labels: [ContextLabel!] @generated ResponseLabels: ContextLabelNode @internal @graphql(key: "labels(first: 200)") MergeRequest: ContextMergeRequest @internal @graphql(key: "mergeRequest(iid: $mr_id)") ResponseGroup: ContextGroup @internal @graphql(key: "group") @@ -180,7 +178,7 @@ type ContextMergeRequest { # DiffStats: [ContextDiffStat!] - Labels: ContextLabels @generated + Labels: [ContextLabel!] @generated ResponseLabels: ContextLabelNode @internal @graphql(key: "labels(first: 200)") ResponseFirstCommits: ContextCommitsNode @internal @graphql(key: "first_commit: commits(first:1)") ResponseLastCommits: ContextCommitsNode @internal @graphql(key: "last_commit: commits(last:1)") From f1960adf7d5de700bb63abb73fa3737d65f51cb2 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:15:49 +0200 Subject: [PATCH 06/11] fix: rename EvaluationResult back --- cmd/shared.go | 2 +- pkg/config/config.go | 2 +- pkg/config/label.go | 12 ++++++------ pkg/scm/types.go | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/shared.go b/cmd/shared.go index f07cb8b..87d325a 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -104,7 +104,7 @@ func runActions(ctx context.Context, client scm.Client, update *scm.UpdateMergeR return nil } -func syncLabels(ctx context.Context, client scm.Client, remote []*scm.Label, required []scm.EvaluationLabelResult) error { +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{} diff --git a/pkg/config/config.go b/pkg/config/config.go index 97ec580..616c2e1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,7 +9,7 @@ type Config struct { Actions Actions `yaml:"actions"` } -func (c Config) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, []Action, error) { +func (c Config) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, []Action, error) { labels, err := c.Labels.Evaluate(evalContext) if err != nil { return nil, nil, err diff --git a/pkg/config/label.go b/pkg/config/label.go index d133002..8ed5de9 100644 --- a/pkg/config/label.go +++ b/pkg/config/label.go @@ -23,8 +23,8 @@ const ( type Labels []*Label -func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, error) { - var results []scm.EvaluationLabelResult +func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) { + var results []scm.EvaluationResult // Evaluate labels for _, label := range labels { @@ -184,7 +184,7 @@ func (p *Label) ShouldSkip(evalContext scm.EvalContext) (bool, error) { return runAndCheckBool(p.skipIfCompiled, evalContext) } -func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResult, error) { +func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) { if err := p.initialize(evalContext); err != nil { return nil, err } @@ -200,7 +200,7 @@ func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResu return nil, err } - var result []scm.EvaluationLabelResult + var result []scm.EvaluationResult switch outputValue := output.(type) { case bool: @@ -243,8 +243,8 @@ func (p *Label) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationLabelResu return result, nil } -func (p Label) resultForLabel(name string, matched bool) scm.EvaluationLabelResult { - return scm.EvaluationLabelResult{ +func (p Label) resultForLabel(name string, matched bool) scm.EvaluationResult { + return scm.EvaluationResult{ Name: name, Matched: matched, Color: p.Color, diff --git a/pkg/scm/types.go b/pkg/scm/types.go index 15040fa..eb1bdae 100644 --- a/pkg/scm/types.go +++ b/pkg/scm/types.go @@ -115,7 +115,7 @@ type Response struct { // LastLink string } -type EvaluationLabelResult struct { +type EvaluationResult struct { // Name of the label being generated. // // May only be used with [conditional] labelling type @@ -149,7 +149,7 @@ type EvaluationActionResult struct { Then []EvaluationActionStep } -func (local EvaluationLabelResult) IsEqual(remote *Label) bool { +func (local EvaluationResult) IsEqual(remote *Label) bool { if local.Name != remote.Name { return false } From 43b1ce8fa91532f3df7fd4d7d1e2c50e7493b9dd Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:25:47 +0200 Subject: [PATCH 07/11] docs: document merge_request.has_label --- README.md | 8 ++++++++ schema/gitlab.go | 3 +-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c7e375..4b02389 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,14 @@ The file patterns use the [`.gitignore` format](https://git-scm.com/docs/gitigno merge_request.modified_files("*.go", "docs/") ``` +#### `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` Returns the [`time.Duration`](https://pkg.go.dev/time#Duration) value of the given string str. diff --git a/schema/gitlab.go b/schema/gitlab.go index b0226e0..befb30e 100644 --- a/schema/gitlab.go +++ b/schema/gitlab.go @@ -66,7 +66,6 @@ func main() { func nest(props []*Property) { for _, field := range props { - if field.IsCustomType { for _, nested := range PropMap[field.Type].Attributes { field.AddAttribute(&Property{ @@ -175,6 +174,7 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { fieldProperty := &Property{ Name: exprTags.Name, Optional: field.Omittable || strings.HasPrefix(fieldType, "*"), + IsSlice: strings.HasPrefix(fieldType, "[]"), Description: field.Description, } @@ -196,7 +196,6 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { } fieldProperty.Type = strings.TrimPrefix(fieldType, "*") - fieldProperty.IsSlice = strings.HasPrefix(fieldType, "[]") || fieldType == "label" modelProperty.AddAttribute(fieldProperty) } // end expr tag is set From b5db6035724d876d3ef98371f3150eb860926105 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:41:04 +0200 Subject: [PATCH 08/11] feat: add 'merge_request.modified_files_list(patterns...)' that return the list of files modified, matching the optional pattern(s) --- .scm-engine.example.yml | 6 ++--- README.md | 12 +++++++++- pkg/scm/gitlab/context_merge_request.go | 30 ++++++++++++++++++++----- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.scm-engine.example.yml b/.scm-engine.example.yml index 1936b36..af40b46 100644 --- a/.scm-engine.example.yml +++ b/.scm-engine.example.yml @@ -98,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() @@ -113,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/" + #) }) diff --git a/README.md b/README.md index 4b02389..db1967f 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,17 @@ 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` diff --git a/pkg/scm/gitlab/context_merge_request.go b/pkg/scm/gitlab/context_merge_request.go index 9fc9463..41d7d33 100644 --- a/pkg/scm/gitlab/context_merge_request.go +++ b/pkg/scm/gitlab/context_merge_request.go @@ -17,10 +17,21 @@ func (e ContextMergeRequest) HasLabel(in string) bool { return false } +func (e ContextMergeRequest) ModifiedFilesList(patterns ...string) []string { + return e.findModifiedFiles(patterns...) +} + // Partially lifted from https://github.com/hmarr/codeowners/blob/main/match.go func (e ContextMergeRequest) ModifiedFiles(patterns ...string) bool { + return len(e.findModifiedFiles(patterns...)) > 0 +} + +// Partially lifted from https://github.com/hmarr/codeowners/blob/main/match.go +func (e ContextMergeRequest) findModifiedFiles(patterns ...string) []string { leftAnchoredLiteral := false + output := []string{} + for _, pattern := range patterns { if !strings.ContainsAny(pattern, "*?\\") && pattern[0] == '/' { leftAnchoredLiteral = true @@ -31,6 +42,7 @@ func (e ContextMergeRequest) ModifiedFiles(patterns ...string) bool { panic(err) } + NEXT_FILE: for _, changedFile := range e.DiffStats { // Normalize Windows-style path separators to forward slashes testPath := filepath.ToSlash(changedFile.Path) @@ -45,27 +57,35 @@ func (e ContextMergeRequest) ModifiedFiles(patterns ...string) bool { // If the pattern ends with a slash we can do a simple prefix match if prefix[len(prefix)-1] == '/' && strings.HasPrefix(testPath, prefix) { - return true + output = append(output, testPath) + + continue NEXT_FILE } // If the strings are the same length, check for an exact match if len(testPath) == len(prefix) && testPath == prefix { - return true + output = append(output, testPath) + + continue NEXT_FILE } // Otherwise check if the test path is a subdirectory of the pattern if len(testPath) > len(prefix) && testPath[len(prefix)] == '/' && testPath[:len(prefix)] == prefix { - return true + output = append(output, testPath) + + continue NEXT_FILE } } if regex.MatchString(testPath) { - return true + output = append(output, testPath) + + continue NEXT_FILE } } } - return false + return output } // buildPatternRegex compiles a new regexp object from a gitignore-style pattern string From bae50e66d6ecf9bfa91042f11be3ad3283ce5f66 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 01:42:34 +0200 Subject: [PATCH 09/11] docs: update examples to check the MR state --- .scm-engine.example.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.scm-engine.example.yml b/.scm-engine.example.yml index af40b46..4428656 100644 --- a/.scm-engine.example.yml +++ b/.scm-engine.example.yml @@ -2,20 +2,20 @@ actions: - name: Warn that the ticket if older than 30 days - if: merge_request.time_since_last_commit > duration("30d") && not merge_request.has_label("do-not-close") + 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.time_since_last_commit > duration("45d") && not merge_request.has_label("do-not-close") + 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: not merge_request.approved && merge_request.has_label("break-glass-approve") + if: merge_request.state != "closed" && not merge_request.approved && merge_request.has_label("break-glass-approve") then: - action: approve - action: comment From f20266f5e7d6888394fed40a869cab1f6efa5f63 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 03:02:30 +0200 Subject: [PATCH 10/11] docs: update README with new 'merge_request.modified_files_list' func --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index db1967f..639f169 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,10 @@ 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 + merge_request.modified_files_list("pkg/service/") // Generate a list of all file paths that was changed in the Merge Request inside 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 ``` ### `label` (list) @@ -301,11 +300,10 @@ 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 + merge_request.modified_files_list("pkg/service/") // Generate a list of all file paths that was changed in the Merge Request inside 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 ``` #### `label.color` (required) From 1ee706d46b28b961a2648f6d7697fbb37e25a233 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Wed, 8 May 2024 03:04:39 +0200 Subject: [PATCH 11/11] docs: make README examples more readable --- README.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 639f169..a3fdbaf 100644 --- a/README.md +++ b/README.md @@ -187,10 +187,17 @@ label: color: "$pink" # From this script, returning a list of labels script: > - merge_request.modified_files_list("pkg/service/") // Generate a list of all file paths that was changed in the Merge Request inside 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) @@ -300,10 +307,17 @@ label: description: "Modified this service directory" color: "$pink" script: > - merge_request.modified_files_list("pkg/service/") // Generate a list of all file paths that was changed in the Merge Request inside 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)