Skip to content

Commit dd50d54

Browse files
committed
feat: add configuration allowing scm-engine to ignore specific users activity when calculating inactivity on a MR/PR
1 parent dd66ce6 commit dd50d54

15 files changed

+162
-27
lines changed

cmd/shared.go

+6
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ func getClient(ctx context.Context) (scm.Client, error) {
2828
}
2929

3030
func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, event any) (err error) {
31+
// Write the config to context so we can pull it out later
32+
ctx = config.WithConfig(ctx, cfg)
33+
3134
// Stop the pipeline when we leave this func
3235
defer func() {
3336
if stopErr := client.Stop(ctx, err); stopErr != nil {
@@ -61,6 +64,9 @@ func ProcessMR(ctx context.Context, client scm.Client, cfg *config.Config, event
6164
}
6265

6366
evalContext.SetWebhookEvent(event)
67+
// Add our "ctx" to evalContext so Expr-Lang functions can reference them
68+
// when they need to read our "cfg"
69+
evalContext.SetContext(ctx)
6470

6571
slogctx.Info(ctx, "Evaluating context")
6672

docs/configuration.md

+24
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ The default configuration filename is `.scm-engine.yml`, either in current worki
44

55
The file path can be changed via `--config` CLI flag and `#!css $SCM_ENGINE_CONFIG_FILE` environment variable.
66

7+
## `ignore_activity_from` {#actions data-toc-label="ignore_activity_from"}
8+
9+
!!! question "What are activity?"
10+
11+
SCM-Engine defines activity as comments, reviews, commits, adding/removing labels and similar.
12+
13+
*Generally*, `activity` is what you see in the Merge/Pull Request `timeline` in the browser UI.
14+
15+
Configure what users that should be ignored when considering activity on a Merge Request
16+
17+
### `ignore_activity_from.bots` {#actions data-toc-label="bots"}
18+
19+
Should `bot` users be ignored when considering activity? Default: `false`
20+
21+
### `ignore_activity_from.usernames[]` {#actions data-toc-label="usernames"}
22+
23+
A list of usernames that should be ignored when considering user activity. Default: `[]`
24+
25+
### `ignore_activity_from.emails[]` {#actions data-toc-label="emails"}
26+
27+
A list of emails that should be ignored when considering user activity. Default: `[]`
28+
29+
**NOTE:** If a user do not have a public email configured on their profile, that users activity will never match this rule.
30+
731
## `actions[]` {#actions data-toc-label="actions"}
832

933
!!! question "What are actions?"

pkg/config/action.go

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"github.com/expr-lang/expr"
5+
"github.com/expr-lang/expr/patcher"
56
"github.com/expr-lang/expr/vm"
67
"github.com/jippi/scm-engine/pkg/scm"
78
"github.com/jippi/scm-engine/pkg/stdlib"
@@ -47,6 +48,7 @@ func (p *Action) initialize(evalContext scm.EvalContext) (*vm.Program, error) {
4748
opts = append(opts, expr.Env(evalContext))
4849
opts = append(opts, stdlib.FunctionRenamer)
4950
opts = append(opts, stdlib.Functions...)
51+
opts = append(opts, expr.Patch(patcher.WithContext{Name: "ctx"}))
5052

5153
return expr.Compile(p.If, opts...)
5254
}

pkg/config/config.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import (
99
)
1010

1111
type Config struct {
12-
Labels Labels `yaml:"label"`
13-
Actions Actions `yaml:"actions"`
12+
Labels Labels `yaml:"label"`
13+
Actions Actions `yaml:"actions"`
14+
IgnoreActivityFrom IgnoreActivityFrom `yaml:"ignore_activity_from"`
1415
}
1516

1617
func (c Config) Evaluate(ctx context.Context, evalContext scm.EvalContext) ([]scm.EvaluationResult, []Action, error) {
1718
slogctx.Info(ctx, "Evaluating labels")
1819

19-
labels, err := c.Labels.Evaluate(evalContext)
20+
labels, err := c.Labels.Evaluate(ctx, evalContext)
2021
if err != nil {
2122
return nil, nil, fmt.Errorf("evaluation failed: %w", err)
2223
}

pkg/config/context.go

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package config
2+
3+
import (
4+
"context"
5+
)
6+
7+
type contextKey uint
8+
9+
const (
10+
configKey contextKey = iota
11+
)
12+
13+
func WithConfig(ctx context.Context, config *Config) context.Context {
14+
return context.WithValue(ctx, configKey, config)
15+
}
16+
17+
func FromContext(ctx context.Context) *Config {
18+
return ctx.Value(configKey).(*Config) //nolint:forcetypeassert
19+
}

pkg/config/ignore_activity_from.go

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package config
2+
3+
type ActorMatcher struct {
4+
Username string
5+
Email *string
6+
IsBot bool
7+
}
8+
9+
type IgnoreActivityFrom struct {
10+
IsBot bool `yaml:"bots"`
11+
Usernames []string `yaml:"usernames"`
12+
Emails []string `yaml:"emails"`
13+
}
14+
15+
func (i IgnoreActivityFrom) Matches(actor ActorMatcher) bool {
16+
// If actor is bot and we ignore bot activity
17+
if actor.IsBot && i.IsBot {
18+
return true
19+
}
20+
21+
// Check if the actor username is in the ignore list
22+
for _, username := range i.Usernames {
23+
if username == actor.Username {
24+
return true
25+
}
26+
}
27+
28+
// If the actor don't have an email, we did not find a match, since
29+
// our last check is on emails
30+
if actor.Email == nil {
31+
return false
32+
}
33+
34+
// Check if the actor email matches any of the ignored ones
35+
for _, email := range i.Emails {
36+
if email == *actor.Email {
37+
return true
38+
}
39+
}
40+
41+
return false
42+
}

pkg/config/label.go

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package config
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"reflect"
78

89
"github.com/expr-lang/expr"
10+
"github.com/expr-lang/expr/patcher"
911
"github.com/expr-lang/expr/vm"
1012
"github.com/jippi/scm-engine/pkg/scm"
1113
"github.com/jippi/scm-engine/pkg/stdlib"
@@ -23,7 +25,7 @@ const (
2325

2426
type Labels []*Label
2527

26-
func (labels Labels) Evaluate(evalContext scm.EvalContext) ([]scm.EvaluationResult, error) {
28+
func (labels Labels) Evaluate(ctx context.Context, evalContext scm.EvalContext) ([]scm.EvaluationResult, error) {
2729
var results []scm.EvaluationResult
2830

2931
// Evaluate labels
@@ -155,6 +157,7 @@ func (p *Label) initialize(evalContext scm.EvalContext) error {
155157
opts = append(opts, expr.Env(evalContext))
156158
opts = append(opts, stdlib.FunctionRenamer)
157159
opts = append(opts, stdlib.Functions...)
160+
opts = append(opts, expr.Patch(patcher.WithContext{Name: "ctx"}))
158161

159162
p.scriptCompiled, err = expr.Compile(p.Script, opts...)
160163
if err != nil {
@@ -170,6 +173,7 @@ func (p *Label) initialize(evalContext scm.EvalContext) error {
170173
opts = append(opts, expr.Env(evalContext))
171174
opts = append(opts, stdlib.FunctionRenamer)
172175
opts = append(opts, stdlib.Functions...)
176+
opts = append(opts, expr.Patch(patcher.WithContext{Name: "ctx"}))
173177

174178
p.skipIfCompiled, err = expr.Compile(p.SkipIf, opts...)
175179
if err != nil {

pkg/scm/github/context.go

+4
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ func (c *Context) SetWebhookEvent(in any) {
8585
c.WebhookEvent = in
8686
}
8787

88+
func (c *Context) SetContext(ctx context.Context) {
89+
c.Context = ctx
90+
}
91+
8892
func (c *Context) GetDescription() string {
8993
return c.PullRequest.Body
9094
}

pkg/scm/gitlab/client_actioner.go

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010

1111
"github.com/expr-lang/expr"
12+
"github.com/expr-lang/expr/patcher"
1213
"github.com/jippi/scm-engine/pkg/scm"
1314
"github.com/jippi/scm-engine/pkg/state"
1415
"github.com/jippi/scm-engine/pkg/stdlib"
@@ -64,6 +65,7 @@ func (c *Client) ApplyStep(ctx context.Context, evalContext scm.EvalContext, upd
6465
opts = append(opts, expr.Env(evalContext))
6566
opts = append(opts, stdlib.FunctionRenamer)
6667
opts = append(opts, stdlib.Functions...)
68+
opts = append(opts, expr.Patch(patcher.WithContext{Name: "ctx"}))
6769

6870
program, err := expr.Compile(fmt.Sprintf("%s", script), opts...)
6971
if err != nil {

pkg/scm/gitlab/context.go

+4
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ func (c *Context) SetWebhookEvent(in any) {
9494
c.WebhookEvent = in
9595
}
9696

97+
func (c *Context) SetContext(ctx context.Context) {
98+
c.Context = ctx
99+
}
100+
97101
func (c *Context) GetDescription() string {
98102
if c.MergeRequest.Description == nil {
99103
return ""

pkg/scm/gitlab/context_merge_request.go

+23-23
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package gitlab
22

33
import (
4+
"context"
45
"fmt"
56
"time"
67

8+
"github.com/jippi/scm-engine/pkg/config"
79
"github.com/jippi/scm-engine/pkg/scm"
810
"github.com/jippi/scm-engine/pkg/stdlib"
911
)
@@ -51,48 +53,51 @@ func (e ContextMergeRequest) StateIsNot(anyOf ...string) bool {
5153
}
5254

5355
// has_no_activity_within
54-
func (e ContextMergeRequest) HasNoActivityWithin(input any) bool {
55-
return !e.HasAnyActivityWithin(input)
56+
func (e ContextMergeRequest) HasNoActivityWithin(ctx context.Context, input any) bool {
57+
return !e.HasAnyActivityWithin(ctx, input)
5658
}
5759

5860
// has_any_activity_within
59-
func (e ContextMergeRequest) HasAnyActivityWithin(input any) bool {
61+
func (e ContextMergeRequest) HasAnyActivityWithin(ctx context.Context, input any) bool {
6062
dur := stdlib.ToDuration(input)
6163
now := time.Now()
64+
cfg := config.FromContext(ctx)
6265

6366
for _, note := range e.Notes {
64-
if now.Sub(note.UpdatedAt) < dur {
65-
return true
67+
// Check if we should ignore the actor (user) activity
68+
if cfg.IgnoreActivityFrom.Matches(note.Author.ToActorMatcher()) {
69+
continue
6670
}
67-
}
6871

69-
if e.LastCommit != nil {
70-
if now.Sub(*e.LastCommit.CommittedDate) < dur {
72+
// Check is within the configured duration
73+
if now.Sub(note.UpdatedAt) < dur {
7174
return true
7275
}
7376
}
7477

75-
return false
78+
// If we have a recent commit, check if its within the duration
79+
return e.LastCommit != nil && now.Sub(*e.LastCommit.CommittedDate) < dur
7680
}
7781

7882
// has_no_user_activity_within
79-
func (e ContextMergeRequest) HasNoUserActivityWithin(input any) bool {
80-
return !e.HasUserActivityWithin(input)
83+
func (e ContextMergeRequest) HasNoUserActivityWithin(ctx context.Context, input any) bool {
84+
return !e.HasUserActivityWithin(ctx, input)
8185
}
8286

8387
// has_user_activity_within
84-
func (e ContextMergeRequest) HasUserActivityWithin(input any) bool {
88+
func (e ContextMergeRequest) HasUserActivityWithin(ctx context.Context, input any) bool {
8589
dur := stdlib.ToDuration(input)
8690
now := time.Now()
91+
cfg := config.FromContext(ctx)
8792

8893
for _, note := range e.Notes {
89-
// Ignore "my" activity
90-
if e.CurrentUser.Username == note.Author.Username {
94+
// Check if we should ignore the actor (user) activity
95+
if cfg.IgnoreActivityFrom.Matches(note.Author.ToActorMatcher()) {
9196
continue
9297
}
9398

94-
// Ignore bots
95-
if e.Author.Bot {
99+
// Ignore "scm-engine" activity since we shouldn't consider ourself a user
100+
if e.CurrentUser.Username == note.Author.Username {
96101
continue
97102
}
98103

@@ -101,13 +106,8 @@ func (e ContextMergeRequest) HasUserActivityWithin(input any) bool {
101106
}
102107
}
103108

104-
if e.LastCommit != nil {
105-
if now.Sub(*e.LastCommit.CommittedDate) < dur {
106-
return true
107-
}
108-
}
109-
110-
return false
109+
// If we have a recent commit, check if its within the duration
110+
return e.LastCommit != nil && now.Sub(*e.LastCommit.CommittedDate) < dur
111111
}
112112

113113
func (e ContextMergeRequest) ModifiedFilesList(patterns ...string) []string {

pkg/scm/gitlab/context_user.go

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package gitlab
2+
3+
import "github.com/jippi/scm-engine/pkg/config"
4+
5+
func (u ContextUser) ToActorMatcher() config.ActorMatcher {
6+
return config.ActorMatcher{
7+
Username: u.Username,
8+
IsBot: u.Bot,
9+
Email: u.PublicEmail,
10+
}
11+
}

pkg/scm/interfaces.go

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type MergeRequestClient interface {
2929
type EvalContext interface {
3030
IsValid() bool
3131
SetWebhookEvent(in any)
32+
SetContext(ctx context.Context)
3233
GetDescription() string
3334
}
3435

schema/gitlab.schema.graphqls

+3
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ directive @expr(key: String!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
1616
directive @graphql(key: String!) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
1717

1818
# Add time.Time support
19+
# See: https://gqlgen.com/reference/scalars/#time
1920
scalar Time
2021

2122
# Add time.Duration support
23+
# See: https://gqlgen.com/reference/scalars/#duration
2224
scalar Duration
2325

2426
# Add 'any' type for Event
27+
# See: https://gqlgen.com/reference/scalars/#any
2528
scalar Any
2629

2730
type Context {

schema/main.go

+12
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"cmp"
77
_ "embed"
88
"fmt"
9+
"go/types"
910
"os"
1011
"os/exec"
1112
"path/filepath"
@@ -244,6 +245,17 @@ func mutateHook(b *modelgen.ModelBuild) *modelgen.ModelBuild {
244245
field.Tag = tags.String()
245246
} // end fields loop
246247

248+
// Manually inject certain "expr env" fields that we can't reasonable create in graphql schema
249+
if model.Name == "Context" {
250+
model.Fields = append(model.Fields, &modelgen.Field{
251+
Name: "Context",
252+
Description: "Go context used to pass around configuration (do not use directly!)",
253+
GoName: "Context",
254+
Type: types.NewNamed(types.NewTypeName(0, types.NewPackage("context", "context"), "Context", nil), nil, nil),
255+
Tag: `expr:"ctx" graphql:"-"`,
256+
})
257+
}
258+
247259
if strings.HasSuffix(model.Name, "Node") || model.Name == "Query" {
248260
continue
249261
}

0 commit comments

Comments
 (0)