This repository has been archived by the owner on Mar 11, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 86
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
b688f8f
commit 09d3745
Showing
9 changed files
with
737 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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{} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.