Skip to content

Commit

Permalink
feat: Assign Reviewers from Gitlab Code Owners Approvers (#98)
Browse files Browse the repository at this point in the history
* Update gitlab schema to include approval rule data in context

* Require int for an action

* Optional string enum as well

* Schema for assignees and reviewers should unnest from nodes

* Base dry run action

* Add schema for assign reviewers action step

* Get Code owners interface

* Limit reviewers

* Skip bot users

* Set cant guarantee order

* Seed random

* Need user ID to update MR reviewers

* Pass ID to actor

* Parse IDs as ints for create merge request func

* Update pkg/scm/gitlab/client_actioner.go

Co-authored-by: Christian Winther <[email protected]>

* Code review suggestions

* Update nilable rule type

* Actors types

* Also add method

* Remove linear mode

* Append review ids to update

* Fix interface

* Cast context to gitlab context and seperate action into new file

* Add tests for get code owners

* Repackage test

* Tests for assign reviewers acton

* Example

* Should control when reviewers are assigned at action level

* Doc ref to ApprovalRuleType

* Skip id -1 check

* Default to codeowners source

* Add test for higher limit

* Optional limit

* Guard against nil rule type

* Set ctx with random source

* Make action options optional

* Also set enum in json schema

---------

Co-authored-by: Christian Winther <[email protected]>
  • Loading branch information
bbckr and jippi authored Dec 25, 2024
1 parent b790d0f commit f6c3788
Show file tree
Hide file tree
Showing 16 changed files with 644 additions and 0 deletions.
10 changes: 10 additions & 0 deletions .scm-engine.gitlab.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ actions:
- action: comment
message: "Approving the MR since it has the 'break-glass-approve' label. Talk to ITGC about this!"

- name: Assign Reviewers to MR (only when MR is unassigned)
group: assign_mr
if: |1
merge_request.state_is("opened")
&& not merge_request.approved
then:
- action: assign_reviewers
source: codeowners
limit: 1

label:
- name: lang/go
color: $indigo
Expand Down
2 changes: 2 additions & 0 deletions cmd/gitlab_evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"time"

"github.com/jippi/scm-engine/pkg/config"
"github.com/jippi/scm-engine/pkg/scm"
Expand All @@ -16,6 +17,7 @@ func Evaluate(cCtx *cli.Context) error {
ctx = state.WithProjectID(ctx, cCtx.String(FlagSCMProject))
ctx = state.WithToken(ctx, cCtx.String(FlagAPIToken))
ctx = state.WithUpdatePipeline(ctx, cCtx.Bool(FlagUpdatePipeline), cCtx.String(FlagUpdatePipelineURL))
ctx = state.WithRandomSeed(ctx, time.Now().UnixNano()) // weak seed since only used for codeowner selection

cfg, err := config.LoadFile(state.ConfigFilePath(ctx))
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.47.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/sync v0.10.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
Expand Down
80 changes: 80 additions & 0 deletions pkg/config/action_step.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type actionList struct {
var actions = []actionList{
{name: "add_label", instance: AddLabelAction{}},
{name: "approve", instance: ApproveAction{}},
{name: "assign_reviewers", instance: AssignReviewers{}},
{name: "close", instance: CloseAction{}},
{name: "comment", instance: CommentAction{}},
{name: "lock_discussion", instance: LockDiscussionAction{}},
Expand Down Expand Up @@ -72,6 +73,17 @@ type CommentAction struct {
Message string `json:"message" yaml:"message"`
}

type AssignReviewers struct {
BaseAction

// The source of the reviewers
Source *string `json:"source,omitempty" yaml:"source,omitempty" jsonschema:"enum=codeowners"`
// The max number of reviewers to assign
Limit int `json:"limit,omitempty" yaml:"limit,omitempty"`
// The mode of assigning reviewers
Mode string `json:"mode,omitempty" yaml:"mode,omitempty" jsonschema:"enum=random"`
}

type AddLabelAction struct {
BaseAction

Expand Down Expand Up @@ -149,6 +161,20 @@ func (step ActionStep) JSONSchema() *jsonschema.Schema {
}
}

func (step ActionStep) RequiredInt(name string) (int, error) {
value, ok := step[name]
if !ok {
return 0, fmt.Errorf("Required 'step' key '%s' is missing", name)
}

valueInt, ok := value.(int)
if !ok {
return 0, fmt.Errorf("Required 'step' key '%s' must be of type int, got %T", name, value)
}

return valueInt, nil
}

func (step ActionStep) RequiredString(name string) (string, error) {
value, ok := step[name]
if !ok {
Expand All @@ -163,6 +189,40 @@ func (step ActionStep) RequiredString(name string) (string, error) {
return valueString, nil
}

func (step ActionStep) RequiredStringEnum(name string, values ...string) (string, error) {
value, ok := step[name]
if !ok {
return "", fmt.Errorf("Required 'step' key '%s' is missing", name)
}

valueString, ok := value.(string)
if !ok {
return "", fmt.Errorf("Required 'step' key '%s' must be of type string, got %T", name, value)
}

for _, validValue := range values {
if valueString == validValue {
return valueString, nil
}
}

return "", fmt.Errorf("Required 'step' key '%s' must be one of %v, got %s", name, values, valueString)
}

func (step ActionStep) OptionalInt(name string, fallback int) (int, error) {
value, ok := step[name]
if !ok {
return fallback, nil
}

valueInt, ok := value.(int)
if !ok {
return fallback, fmt.Errorf("Optional step field '%s' must be of type int, got %T", name, value)
}

return valueInt, nil
}

func (step ActionStep) OptionalString(name, defaultValue string) (string, error) {
value, ok := step[name]
if !ok {
Expand All @@ -177,6 +237,26 @@ func (step ActionStep) OptionalString(name, defaultValue string) (string, error)
return valueString, nil
}

func (step ActionStep) OptionalStringEnum(name string, fallback string, values ...string) (string, error) {
value, ok := step[name]
if !ok {
return fallback, nil
}

valueString, ok := value.(string)
if !ok {
return fallback, fmt.Errorf("Optional step field '%s' must be of type string, got %T", name, value)
}

for _, validValue := range values {
if valueString == validValue {
return valueString, nil
}
}

return fallback, fmt.Errorf("Optional step field '%s' must be one of %v, got %s", name, values, valueString)
}

func (step ActionStep) Get(name string) (any, error) {
value, ok := step[name]
if !ok {
Expand Down
10 changes: 10 additions & 0 deletions pkg/scm/github/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,13 @@ func (c *Context) HasExecutedActionGroup(name string) bool {
func (c *Context) AllowPipelineFailure(ctx context.Context) bool {
return len(c.PullRequest.findModifiedFiles(state.ConfigFilePath(ctx))) == 1
}

func (c *Context) GetCodeOwners() scm.Actors {
// unimplemented
return make(scm.Actors, 0)
}

func (c *Context) GetReviewers() scm.Actors {
// unimplemented
return make(scm.Actors, 0)
}
3 changes: 3 additions & 0 deletions pkg/scm/gitlab/client_actioner.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ func (c *Client) ApplyStep(ctx context.Context, evalContext scm.EvalContext, upd

return err

case "assign_reviewers":
return c.AssignReviewers(ctx, evalContext, update, step)

case "comment":
message, err := step.RequiredString("message")
if err != nil {
Expand Down
96 changes: 96 additions & 0 deletions pkg/scm/gitlab/client_actioner_assign.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package gitlab

import (
"context"
"log/slog"

"github.com/jippi/scm-engine/pkg/scm"
"github.com/jippi/scm-engine/pkg/state"
slogctx "github.com/veqryn/slog-context"
)

func (c *Client) AssignReviewers(ctx context.Context, evalContext scm.EvalContext, update *scm.UpdateMergeRequestOptions, step scm.ActionStep) error {
source, err := step.OptionalStringEnum("source", "codeowners", "codeowners")
if err != nil {
return err
}

desiredLimit, err := step.OptionalInt("limit", 1)
if err != nil {
return err
}

mode, err := step.OptionalStringEnum("mode", "random", "random")
if err != nil {
return err
}

// prevents misuse and situations where evaluate will assign reviewers endlessly
existingReviewers := evalContext.GetReviewers()
if len(existingReviewers) > 0 {
slogctx.Debug(ctx, "Reviewers already assigned", slog.Any("reviewers", existingReviewers))

return nil
}

var eligibleReviewers []scm.Actor

switch source {
case "codeowners":
eligibleReviewers = evalContext.GetCodeOwners()

break
}

if len(eligibleReviewers) == 0 {
slogctx.Debug(ctx, "No eligible reviewers found")

return nil
}

var reviewers scm.Actors

limit := desiredLimit
if limit > len(eligibleReviewers) {
limit = len(eligibleReviewers)
}

switch mode {
case "random":
reviewers = make(scm.Actors, limit)

rand := state.RandomSeed(ctx)
perm := rand.Perm(len(eligibleReviewers))

for i := 0; i < limit; i++ {
reviewers[i] = eligibleReviewers[perm[i]]
}

break
}

reviewerIDs := make([]int, 0, len(reviewers))

for _, reviewer := range reviewers {
id := reviewer.IntID()

// skip invalid int ids, this should not happen but still safeguard against it
if id == 0 {
slogctx.Warn(ctx, "Invalid reviewer ID", slog.String("id", reviewer.ID))

continue
}

reviewerIDs = append(reviewerIDs, id)
}

if state.IsDryRun(ctx) {
slogctx.Info(ctx, "(Dry Run) Assigning MR", slog.String("source", source), slog.Int("limit", limit), slog.String("mode", mode), slog.Any("reviewers", reviewers))

return nil
}

update.AppendReviewerIDs(reviewerIDs)

return nil
}
Loading

0 comments on commit f6c3788

Please sign in to comment.