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/.scm-engine.example.yml b/.scm-engine.example.yml index 9fcf208..4428656 100644 --- a/.scm-engine.example.yml +++ b/.scm-engine.example.yml @@ -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" @@ -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() @@ -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/" + #) }) diff --git a/README.md b/README.md index 5c7e375..a3fdbaf 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) @@ -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` diff --git a/cmd/shared.go b/cmd/shared.go index 1269d1a..87d325a 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.EvaluationResult) 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/go.mod b/go.mod index 4f49f91..e6c80e2 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.46 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..616c2e1 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.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 } diff --git a/pkg/config/label.go b/pkg/config/label.go index 0b27bcc..8ed5de9 100644 --- a/pkg/config/label.go +++ b/pkg/config/label.go @@ -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 { @@ -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 } diff --git a/pkg/scm/gitlab/client_actioner.go b/pkg/scm/gitlab/client_actioner.go new file mode 100644 index 0000000..8f8796c --- /dev/null +++ b/pkg/scm/gitlab/client_actioner.go @@ -0,0 +1,73 @@ +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 "approve": + _, _, err := c.wrapped.MergeRequestApprovals.ApproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx), &gitlab.ApproveMergeRequestOptions{}) + + return err + + case "unapprove": + _, err := c.wrapped.MergeRequestApprovals.UnapproveMergeRequest(state.ProjectIDFromContext(ctx), state.MergeRequestIDFromContextInt(ctx)) + + return err + + 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") + } + + _, _, 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"]) + } + + return nil +} diff --git a/pkg/scm/gitlab/context_merge_request.go b/pkg/scm/gitlab/context_merge_request.go index aaa9dc2..41d7d33 100644 --- a/pkg/scm/gitlab/context_merge_request.go +++ b/pkg/scm/gitlab/context_merge_request.go @@ -7,10 +7,31 @@ import ( "strings" ) +func (e ContextMergeRequest) HasLabel(in string) bool { + for _, label := range e.Labels { + if label.Title == in { + return true + } + } + + 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 @@ -21,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) @@ -35,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 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..eb1bdae 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"` @@ -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 EvaluationResult) 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) } 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..befb30e 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, } @@ -93,7 +93,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 +145,7 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { modelProperty := &Property{ Name: modelName, - Type: "model", + Type: modelName, Description: model.Description, } @@ -174,11 +174,10 @@ 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, } - fieldProperty.IsSlice = strings.HasPrefix(fieldType, "[]") - if strings.Contains(fieldType, "github.com/jippi/scm-engine") { fieldType = filepath.Base(fieldType) fieldType = strings.Split(fieldType, ".")[1] @@ -199,20 +198,20 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild { fieldProperty.Type = strings.TrimPrefix(fieldType, "*") modelProperty.AddAttribute(fieldProperty) - } + } // 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 } Props = append(Props, modelProperty) - PropMap[modelProperty.Name] = modelProperty - } + PropMap[modelProperty.Type] = modelProperty + } // end model loop return b }