Getting Started
To get a glimpse of what Decisions4s
can do, we'll model rules governing a pull request process.
We want to define four rules:
- An unprotected branch requires 1 approval.
- A protected branch requires 2 approvals.
- An admin can merge anything without approvals, but this sends a notification.
- Nothing can be merged otherwise.
As the first step, add the following dependency to your project:
"org.business4s" %% "decisions4s-core" % "0.0.2"
Defining the Rules
We start by defining the input and output of the decision:
import decisions4s.*
case class Input[F[_]](
numOfApprovals: F[Int],
isTargetBranchProtected: F[Boolean],
authorIsAdmin: F[Boolean],
) derives HKD
case class Output[F[_]](
allowMerging: F[Boolean],
notifyUnusualAction: F[Boolean],
) derives HKD
We take three values as input and provide two values as output. Both input and output are defined as case classes, with each field wrapped in the F[_] type parameter. This pattern is sometimes referred to as Higher Kinded Data, which is also the name of the typeclass automatically derived for our types.
Now let's define the rules:
def rules: List[Rule[Input, Output]] = List(
// Unprotected branch requires 1 approval
Rule(
matching = Input(
numOfApprovals = it > 0,
isTargetBranchProtected = it.isFalse,
authorIsAdmin = it.catchAll,
),
output = Output(allowMerging = true, notifyUnusualAction = false),
),
// Protected branch requires 2 approvals
Rule(
matching = Input(
numOfApprovals = it > 1,
isTargetBranchProtected = it.isTrue,
authorIsAdmin = it.catchAll,
),
output = Output(allowMerging = true, notifyUnusualAction = false),
),
// Admin can merge anything without approvals but this sends a notification
Rule(
matching = Input(
numOfApprovals = it.catchAll,
isTargetBranchProtected = it.catchAll,
authorIsAdmin = it.isTrue,
),
output = Output(allowMerging = true, notifyUnusualAction = true),
),
// Nothing can be merged otherwise
Rule.default(
Output(allowMerging = false, notifyUnusualAction = false),
),
)
And create a decision:
val decisionTable: DecisionTable[Input, Output, HitPolicy.First] =
DecisionTable(rules, name = "PullRequestDecision", HitPolicy.First)
By doing this, we specified a name that will be used for the generated diagram. We also defined the hit policy, which in our case will capture the first satisfied rule.
Evaluating the Decision
Now we can evaluate our decision:
val result: EvalResult.First[Input, Output] = decisionTable.evaluateFirst(
Input[Value](
numOfApprovals = 2,
isTargetBranchProtected = true,
authorIsAdmin = false,
),
)
assert(result.output == Some(Output[Value](allowMerging = true, notifyUnusualAction = false)))
It works!
Understanding the decision
But you might wonder why the given decision was made. Wonder no more!
println(result.makeDiagnosticsString)
Which gives us the following:
Evaluation diagnostics for "PullRequestDecision"
Hit policy: First
Result: Some(Output(true,false))
Input:
numOfApprovals: 2
isTargetBranchProtected: true
authorIsAdmin: false
Rule 0 [✗]:
numOfApprovals [✓]: > 0
isTargetBranchProtected [✗]: false
authorIsAdmin [✓]: -
== ✗
Rule 1 [✓]:
numOfApprovals [✓]: > 1
isTargetBranchProtected [✓]: true
authorIsAdmin [✓]: -
== Output(allowMerging = true, notifyUnusualAction = false)
This not only tells us which rules were satisfied but also shows the results of specific predicates. This way, we can easily understand what happened!
Visualizing the Logic
Let's see how our logic presents itself in a more concise and human-friendly way:
import decisions4s.markdown.MarkdownRenderer
val markdown = MarkdownRenderer.render(decisionTable)
That's the output:
(I) numOfApprovals | (I) isTargetBranchProtected | (I) authorIsAdmin | (O) allowMerging | (O) notifyUnusualAction |
---|---|---|---|---|
> 0 | false | - | true | false |
> 1 | true | - | true | false |
- | - | true | true | true |
- | - | - | false | false |
And if that's not enough, we can also generate the DMN diagram. To do this, we need to add another dependency:
"org.business4s" %% "decisions4s-dmn" % "0.0.2"
And use the provided utilities:
import decisions4s.dmn.DmnRenderer
val dmnXML: String = DmnRenderer.render(decisionTable).toXML
Now if we open this file in bpmn.io or Camunda Modeler, we will see the following table:
This can now be shared with non-technical folks or saved as documentation!