Skip to content
This repository has been archived by the owner on Mar 11, 2021. It is now read-only.

Commit

Permalink
feat(actions): Actions system infra
Browse files Browse the repository at this point in the history
The actions system is a key component for process automation in WIT. It provides a way of executing user-configurable, dynamic process steps depending on user settings, schema settings and events in the WIT.

The current PR provides the basic actions infrastructure and an implementation of an example action rules for testing.
  • Loading branch information
michaelkleinhenz authored Aug 20, 2018
1 parent b688f8f commit 09d3745
Show file tree
Hide file tree
Showing 9 changed files with 737 additions and 0 deletions.
101 changes: 101 additions & 0 deletions actions/actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package actions

/*
The actions system is a key component for process automation in WIT. It provides
a way of executing user-configurable, dynamic process steps depending on user
settings, schema settings and events in the WIT.
The idea here is to provide a simple, yet powerful "publish-subscribe" system that
can connect any "event" in the system to any "action" with a clear decoupling
of events and actions with the goal of making the associations later dynamic and
configurable by the user ("user connects this event to this action"). Think
of a "IFTTT for WIT" (https://en.wikipedia.org/wiki/IFTTT).
Actions are generic and atomic execution steps that do exactly one task and
are configurable. The actions system around the actions provide a key-based
execution of the actions.
Some examples for an application of this system would be:
- closing all children of a parent WI that is being closed (the user connects the
"close" attribute change event of a WI to an action that closes all WIs of
a matching query).
- sending out notifications for mentions on markdown (the system executes an
action "send notification" for every mention found in markdown values).
- moving all WIs from one iteration to the next in the time sequence when
the original iteration is closed.
For all these automations, the actions system provides a re-usable, flexible
and later user configurable way of doing that without creating lots of
custom code and/or custom process implementations that are hardcoded in the
WIT.
*/

import (
"context"

errs "github.com/pkg/errors"
uuid "github.com/satori/go.uuid"

"github.com/fabric8-services/fabric8-wit/actions/change"
"github.com/fabric8-services/fabric8-wit/actions/rules"
"github.com/fabric8-services/fabric8-wit/application"
)

// ExecuteActionsByOldNew executes all actions given in the actionConfigList
// using the mapped configuration strings and returns the new context entity.
// It takes the old version and the new version of the context entity, comparing them.
func ExecuteActionsByOldNew(ctx context.Context, db application.DB, userID uuid.UUID, oldContext change.Detector, newContext change.Detector, actionConfigList map[string]string) (change.Detector, change.Set, error) {
if oldContext == nil || newContext == nil {
return nil, nil, errs.New("execute actions called with nil entities")
}
contextChanges, err := oldContext.ChangeSet(newContext)
if err != nil {
return nil, nil, err
}
return ExecuteActionsByChangeset(ctx, db, userID, newContext, contextChanges, actionConfigList)
}

// ExecuteActionsByChangeset executes all actions given in the actionConfigs
// using the mapped configuration strings and returns the new context entity.
// It takes a []Change that describes the differences between the old and the new context.
func ExecuteActionsByChangeset(ctx context.Context, db application.DB, userID uuid.UUID, newContext change.Detector, contextChanges []change.Change, actionConfigs map[string]string) (change.Detector, change.Set, error) {
var actionChanges change.Set
for actionKey := range actionConfigs {
var err error
actionConfig := actionConfigs[actionKey]
switch actionKey {
case rules.ActionKeyNil:
newContext, actionChanges, err = executeAction(rules.ActionNil{}, actionConfig, newContext, contextChanges, &actionChanges)
case rules.ActionKeyFieldSet:
newContext, actionChanges, err = executeAction(rules.ActionFieldSet{
Db: db,
Ctx: ctx,
UserID: &userID,
}, actionConfig, newContext, contextChanges, &actionChanges)
/* commented out for now until this rule is added
case rules.ActionKeyStateToMetastate:
newContext, actionChanges, err = executeAction(rules.ActionStateToMetaState{
Db: db,
Ctx: ctx,
UserID: &userID,
}, actionConfig, newContext, contextChanges, &actionChanges)
*/
default:
return nil, nil, errs.New("action key " + actionKey + " is unknown")
}
if err != nil {
return nil, nil, err
}
}
return newContext, actionChanges, nil
}

// executeAction executes the action given. The actionChanges contain the changes made by
// prior action executions. The execution is expected to add/update their changes on this
// change set.
func executeAction(act rules.Action, configuration string, newContext change.Detector, contextChanges change.Set, actionChanges *change.Set) (change.Detector, change.Set, error) {
if act == nil {
return nil, nil, errs.New("rule can not be nil")
}
return act.OnChange(newContext, contextChanges, configuration, actionChanges)
}
180 changes: 180 additions & 0 deletions actions/actions_whitebox_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package actions

import (
"testing"

"github.com/fabric8-services/fabric8-wit/gormtestsupport"
"github.com/fabric8-services/fabric8-wit/resource"
tf "github.com/fabric8-services/fabric8-wit/test/testfixture"
"github.com/fabric8-services/fabric8-wit/workitem"

"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)

func TestSuiteAction(t *testing.T) {
resource.Require(t, resource.Database)
suite.Run(t, &ActionSuite{DBTestSuite: gormtestsupport.NewDBTestSuite()})
}

type ActionSuite struct {
gormtestsupport.DBTestSuite
}

func createWICopy(wi workitem.WorkItem, state string, boardcolumns []interface{}) workitem.WorkItem {
var wiCopy workitem.WorkItem
wiCopy.ID = wi.ID
wiCopy.SpaceID = wi.SpaceID
wiCopy.Type = wi.Type
wiCopy.Number = wi.Number
wiCopy.Fields = map[string]interface{}{}
for k := range wi.Fields {
wiCopy.Fields[k] = wi.Fields[k]
}
wiCopy.Fields[workitem.SystemState] = state
wiCopy.Fields[workitem.SystemBoardcolumns] = boardcolumns
return wiCopy
}

func (s *ActionSuite) TestChangeSet() {
fxt := tf.NewTestFixture(s.T(), s.DB, tf.CreateWorkItemEnvironment(), tf.WorkItems(2))

s.T().Run("different ID", func(t *testing.T) {
_, err := fxt.WorkItems[0].ChangeSet(*fxt.WorkItems[1])
require.Error(t, err)
})

s.T().Run("same instance", func(t *testing.T) {
changes, err := fxt.WorkItems[0].ChangeSet(*fxt.WorkItems[0])
require.NoError(t, err)
require.Empty(t, changes)
})

s.T().Run("no changes, same column order", func(t *testing.T) {
wiCopy := createWICopy(*fxt.WorkItems[0], workitem.SystemStateNew, []interface{}{"bcid0", "bcid1"})
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
changes, err := fxt.WorkItems[0].ChangeSet(wiCopy)
require.NoError(t, err)
require.Empty(t, changes)
})

s.T().Run("no changes, mixed column order", func(t *testing.T) {
wiCopy := createWICopy(*fxt.WorkItems[0], workitem.SystemStateNew, []interface{}{"bcid1", "bcid0"})
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
changes, err := fxt.WorkItems[0].ChangeSet(wiCopy)
require.NoError(t, err)
require.Empty(t, changes)
})

s.T().Run("state changes", func(t *testing.T) {
wiCopy := createWICopy(*fxt.WorkItems[0], workitem.SystemStateNew, []interface{}{"bcid0", "bcid1"})
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateOpen
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
changes, err := fxt.WorkItems[0].ChangeSet(wiCopy)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, workitem.SystemState, changes[0].AttributeName)
require.Equal(t, workitem.SystemStateOpen, changes[0].NewValue)
require.Equal(t, workitem.SystemStateNew, changes[0].OldValue)
})

s.T().Run("column changes", func(t *testing.T) {
wiCopy := createWICopy(*fxt.WorkItems[0], workitem.SystemStateNew, []interface{}{"bcid0"})
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
changes, err := fxt.WorkItems[0].ChangeSet(wiCopy)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, workitem.SystemBoardcolumns, changes[0].AttributeName)
require.Equal(t, wiCopy.Fields[workitem.SystemBoardcolumns], changes[0].OldValue)
require.Equal(t, fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns], changes[0].NewValue)
})

s.T().Run("multiple changes", func(t *testing.T) {
wiCopy := createWICopy(*fxt.WorkItems[0], workitem.SystemStateOpen, []interface{}{"bcid0"})
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
changes, err := fxt.WorkItems[0].ChangeSet(wiCopy)
require.NoError(t, err)
require.Len(t, changes, 2)
// we intentionally test the order here as the code under test needs
// to be expanded later, supporting more changes and this is an
// integrity test on the current impl.
require.Equal(t, workitem.SystemState, changes[0].AttributeName)
require.Equal(t, workitem.SystemStateNew, changes[0].NewValue)
require.Equal(t, workitem.SystemStateOpen, changes[0].OldValue)
require.Equal(t, workitem.SystemBoardcolumns, changes[1].AttributeName)
require.Equal(t, fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns], changes[1].NewValue)
require.Equal(t, wiCopy.Fields[workitem.SystemBoardcolumns], changes[1].OldValue)
})

s.T().Run("new instance", func(t *testing.T) {
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{}
changes, err := fxt.WorkItems[0].ChangeSet(nil)
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, workitem.SystemState, changes[0].AttributeName)
require.Equal(t, workitem.SystemStateNew, changes[0].NewValue)
require.Nil(t, changes[0].OldValue)
})
}

func (s *ActionSuite) TestActionExecution() {
fxt := tf.NewTestFixture(s.T(), s.DB, tf.CreateWorkItemEnvironment(), tf.WorkItems(2))
userID := fxt.Identities[0].ID

s.T().Run("by Old New", func(t *testing.T) {
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
newVersion := createWICopy(*fxt.WorkItems[0], workitem.SystemStateOpen, []interface{}{"bcid0", "bcid1"})
_, changes, err := ExecuteActionsByOldNew(s.Ctx, s.GormDB, userID, fxt.WorkItems[0], newVersion, map[string]string{
"Nil": "{ noConfig: 'none' }",
})
require.NoError(t, err)
require.Len(t, changes, 0)
})

s.T().Run("by ChangeSet", func(t *testing.T) {
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
newVersion := createWICopy(*fxt.WorkItems[0], workitem.SystemStateOpen, []interface{}{"bcid0", "bcid1"})
contextChanges, err := fxt.WorkItems[0].ChangeSet(newVersion)
require.NoError(t, err)
afterActionWI, changes, err := ExecuteActionsByChangeset(s.Ctx, s.GormDB, userID, newVersion, contextChanges, map[string]string{
"Nil": "{ noConfig: 'none' }",
})
require.NoError(t, err)
require.Len(t, changes, 0)
require.Equal(t, workitem.SystemStateOpen, afterActionWI.(workitem.WorkItem).Fields["system.state"])
})

s.T().Run("unknown rule", func(t *testing.T) {
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
newVersion := createWICopy(*fxt.WorkItems[0], workitem.SystemStateOpen, []interface{}{"bcid0", "bcid1"})
contextChanges, err := fxt.WorkItems[0].ChangeSet(newVersion)
require.NoError(t, err)
_, _, err = ExecuteActionsByChangeset(s.Ctx, s.GormDB, userID, newVersion, contextChanges, map[string]string{
"unknownRule": "{ noConfig: 'none' }",
})
require.NotNil(t, err)
})

s.T().Run("sideffects", func(t *testing.T) {
fxt.WorkItems[0].Fields[workitem.SystemState] = workitem.SystemStateNew
fxt.WorkItems[0].Fields[workitem.SystemBoardcolumns] = []interface{}{"bcid0", "bcid1"}
newVersion := createWICopy(*fxt.WorkItems[0], workitem.SystemStateOpen, []interface{}{"bcid0", "bcid1"})
contextChanges, err := fxt.WorkItems[0].ChangeSet(newVersion)
require.NoError(t, err)
// Intentionally not using a constant here!
afterActionWI, changes, err := ExecuteActionsByChangeset(s.Ctx, s.GormDB, userID, newVersion, contextChanges, map[string]string{
"FieldSet": "{ \"system.state\": \"resolved\" }",
})
require.NoError(t, err)
require.Len(t, changes, 1)
require.Equal(t, workitem.SystemStateResolved, afterActionWI.(workitem.WorkItem).Fields[workitem.SystemState])
})
}
19 changes: 19 additions & 0 deletions actions/change/changeset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package change

// Set is a set of changes to an entitiy.
type Set []Change

// Detector defines funcs for getting a changeset from two
// instances of a class. This interface has to be implemented by
// all entities that should trigger action rule runs.
type Detector interface {
ChangeSet(older Detector) (Set, error)
}

// Change defines a set of changed values in an entity. It holds
// the attribute name as the key and old and new values.
type Change struct {
AttributeName string
NewValue interface{}
OldValue interface{}
}
40 changes: 40 additions & 0 deletions actions/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
Package actions system is a key component for process automation in WIT. It provides a
way of executing user-configurable, dynamic process steps depending on user
settings, schema settings and events in the WIT.
The idea here is to provide a simple, yet powerful "signal-slot" system that
can connect any "event" in the system to any "action" with a clear decoupling of
events and actions with the goal of making the associations later dynamic and
configurable by the user ("user connects this event to this action"). Think
of a "IFTTT for WIT".
Actions are generic and atomic execution steps that do exactly one task and are
configurable. The actions system around the actions provide a key-based
execution of the actions.
Some examples for an application of this system would be:
* closing all childs of a parent that is being closed (the user connects the
"close" attribute change event of a WI to an action that closes all
WIs of a matching query).
* sending out notifications for mentions on markdown (the system executes
an action "send notification" for every mention found in markdown values).
* moving all WIs from one iteration to the next in the time sequence when
the original iteration is closed.
For all these automations, the actions system provides a re-usable, flexible and
later user configurable way of doing that without creating lots of custom code
and/or custom process implementations that are hardcoded in the WIT.
The current PR provides the basic actions infrastructure and an implementation of
an example action rules for testing.
This package provides two methods ExecuteActionsByOldNew() and ExecuteActionsByChangeset()
that can be called by a client (for example the controller on a request) with an entity
that is the context of the action run and a configuration for the context. The
configuration consists of a list of rule keys (identifying the rules that apply) and
respective configuration for the rules. The actions system will run the rules
sequentially and return the new context entity and a set of changes done while running
the rules. Note that executing actions may have sideffects on data beyond the context.
*/
package actions
28 changes: 28 additions & 0 deletions actions/rules/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package rules

import "github.com/fabric8-services/fabric8-wit/actions/change"

const (
// ActionKeyNil is the key for the ActionKeyNil action rule.
ActionKeyNil = "Nil"
// ActionKeyFieldSet is the key for the ActionKeyFieldSet action rule.
ActionKeyFieldSet = "FieldSet"
// ActionKeyStateToMetastate is the key for the ActionKeyStateToMetastate action rule.
ActionKeyStateToMetastate = "BidirectionalStateToColumn"

// ActionKeyStateToMetastateConfigMetastate is the key for the ActionKeyStateToMetastateConfigMetastate config parameter.
ActionKeyStateToMetastateConfigMetastate = "metaState"
)

// Action defines an action on change of an entity. Executing an
// Action might have sideffects, but will always return the original
// given context with all changes of the Action to this context. Note
// that the execution may have sideeffects on other entities beyond the
// context.
type Action interface {
// OnChange executes this action by looking at a change set of
// updated attributes. It returns the new context. Note that this
// needs the new (after change) context and the old value(s) as
// part of the changeset.
OnChange(newContext change.Detector, contextChanges change.Set, configuration string, actionChanges *change.Set) (change.Detector, change.Set, error)
}
Loading

0 comments on commit 09d3745

Please sign in to comment.