mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
execute triggers
This commit is contained in:
parent
116825334c
commit
c466ca3059
26 changed files with 3084 additions and 73 deletions
|
|
@ -18,6 +18,7 @@ New users: start with [Quick Start](quick-start.md) and [Examples](examples.md).
|
|||
|
||||
- [Syntax](syntax.md): lexical structure and grammar-oriented reference.
|
||||
- [Semantics](semantics.md): statement behavior, trigger structure, qualifier scope, and evaluation model.
|
||||
- [Triggers](triggers.md): configuration, runtime execution model, cascade behavior, and operational patterns.
|
||||
- [Types And Values](types-and-values.md): value categories, literals, `empty`, enums, and schema-dependent typing.
|
||||
- [Operators And Built-ins](operators-and-builtins.md): precedence, operators, built-in functions, and shell-adjacent capabilities.
|
||||
- [Validation And Errors](validation-and-errors.md): parse errors, validation failures, edge cases, and strictness rules.
|
||||
|
|
|
|||
|
|
@ -118,8 +118,11 @@ after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
|||
after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")
|
||||
```
|
||||
|
||||
Triggers are configured in `workflow.yaml` under the `triggers:` key. See [Triggers](triggers.md) for configuration details and runtime behavior.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- Use [Triggers](triggers.md) for configuration, execution model, and runtime behavior.
|
||||
- Use [Syntax](syntax.md) for the grammar-level reference.
|
||||
- Use [Types And Values](types-and-values.md) for the type system and literal rules.
|
||||
- Use [Operators And Built-ins](operators-and-builtins.md) for precedence and function signatures.
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ after create where new.priority <= 2 and new.assignee is empty update where id =
|
|||
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
At runtime, triggers execute in a pipeline: before-triggers run as validators before persistence, the mutation is persisted, then after-triggers run as hooks. For the full execution model, cascade behavior, configuration, and runtime details, see [Triggers](triggers.md).
|
||||
|
||||
## Qualifier scope
|
||||
|
||||
Qualifier rules depend on the event:
|
||||
|
|
|
|||
279
.doc/doki/doc/ruki/triggers.md
Normal file
279
.doc/doki/doc/ruki/triggers.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# Triggers
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What triggers look like](#what-triggers-look-like)
|
||||
- [Configuration](#configuration)
|
||||
- [Patterns](#patterns)
|
||||
- [Tips and gotchas](#tips-and-gotchas)
|
||||
- [Execution pipeline](#execution-pipeline)
|
||||
- [Before-trigger behavior](#before-trigger-behavior)
|
||||
- [After-trigger behavior](#after-trigger-behavior)
|
||||
- [Cascade depth](#cascade-depth)
|
||||
- [The run() action](#the-run-action)
|
||||
- [Configuration discovery details](#configuration-discovery-details)
|
||||
- [Startup and error handling](#startup-and-error-handling)
|
||||
|
||||
## Overview
|
||||
|
||||
Triggers are reactive rules that fire when tikis are created, updated, or deleted. A before-trigger can block a mutation with a denial message. An after-trigger can react to a mutation by creating, updating, deleting tikis, or running a shell command.
|
||||
|
||||
This page covers how triggers are configured, how they execute at runtime, and common patterns. For the grammar, see [Syntax](syntax.md). For structural rules and qualifier scoping, see [Semantics](semantics.md). For parse and validation errors, see [Validation And Errors](validation-and-errors.md).
|
||||
|
||||
## What triggers look like
|
||||
|
||||
A before-trigger guards against unwanted changes:
|
||||
|
||||
```sql
|
||||
-- block completing a tiki that has unfinished dependencies
|
||||
before update
|
||||
where new.status = "done" and dependsOn any status != "done"
|
||||
deny "cannot complete tiki with open dependencies"
|
||||
```
|
||||
|
||||
- `before update` — fires before an update is persisted
|
||||
- `where ...` — the guard condition; the trigger only fires when this matches
|
||||
- `deny "..."` — the rejection message returned to the caller
|
||||
|
||||
An after-trigger automates a reaction:
|
||||
|
||||
```sql
|
||||
-- when a recurring tiki is completed, create the next occurrence
|
||||
after update
|
||||
where new.status = "done" and old.recurrence is not empty
|
||||
create title=old.title priority=old.priority tags=old.tags
|
||||
recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
```
|
||||
|
||||
- `after update` — fires after an update is persisted
|
||||
- `where ...` — the guard; the action only runs when this matches
|
||||
- `create ...` — the action to perform (can also be `update`, `delete`, or `run(...)`)
|
||||
|
||||
Triggers without a `where` clause fire on every matching event:
|
||||
|
||||
```sql
|
||||
-- clean up reverse dependencies whenever a tiki is deleted
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Triggers are defined in `workflow.yaml` under the `triggers:` key. Each entry has two fields:
|
||||
|
||||
- `ruki` — the trigger rule in Ruki syntax (required)
|
||||
- `description` — an optional label
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: "block done with open deps"
|
||||
ruki: >-
|
||||
before update
|
||||
where new.status = "done" and dependsOn any status != "done"
|
||||
deny "resolve dependencies first"
|
||||
|
||||
- description: "auto-assign urgent"
|
||||
ruki: >-
|
||||
after create
|
||||
where new.priority <= 2 and new.assignee is empty
|
||||
update where id = new.id set assignee="booleanmaybe"
|
||||
|
||||
- description: "cleanup deps on delete"
|
||||
ruki: >-
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
`workflow.yaml` is searched in the standard configuration locations described in [Configuration](../config.md#configuration-precedence). If multiple files define a `triggers:` section, the last one wins — cwd overrides project, which overrides user. A file without a `triggers:` key does not override anything. An explicit empty list (`triggers: []`) overrides inherited triggers to zero.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Limit work in progress
|
||||
|
||||
Prevent anyone from having too many in-progress tikis at once:
|
||||
|
||||
```sql
|
||||
before update
|
||||
where new.status = "in progress"
|
||||
and count(select where assignee = new.assignee and status = "in progress") >= 3
|
||||
deny "WIP limit reached for this assignee"
|
||||
```
|
||||
|
||||
The `count(select ...)` evaluates against the candidate state — the proposed update is already reflected in the count, so the limit fires before persistence.
|
||||
|
||||
### Auto-assign urgent work
|
||||
|
||||
Automatically assign high-priority tikis that arrive without an owner:
|
||||
|
||||
```sql
|
||||
after create
|
||||
where new.priority <= 2 and new.assignee is empty
|
||||
update where id = new.id set assignee="booleanmaybe"
|
||||
```
|
||||
|
||||
The after-trigger fires after the tiki is persisted, then updates it with the assignee. This cascades through the mutation gate, so any update validators (like WIP limits) still apply to the auto-assignment.
|
||||
|
||||
### Recurring task creation
|
||||
|
||||
When a recurring tiki is completed, create the next occurrence:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "done" and old.recurrence is not empty
|
||||
create title=old.title priority=old.priority tags=old.tags
|
||||
recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
```
|
||||
|
||||
The new tiki inherits the original's title, priority, tags, and recurrence pattern. Its due date is set to the next occurrence using `next_date()`.
|
||||
|
||||
### Dependency cleanup on delete
|
||||
|
||||
When a tiki is deleted, remove it from every other tiki's `dependsOn` list:
|
||||
|
||||
```sql
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
This fires after every delete, with no guard condition. The `old.id in dependsOn` condition finds tikis that depend on the deleted one, and the set clause removes the reference.
|
||||
|
||||
### Cascade completion
|
||||
|
||||
Auto-complete an epic when all its dependencies are done:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "done"
|
||||
update where id in blocks(old.id) and type = "epic"
|
||||
and dependsOn all status = "done"
|
||||
set status="done"
|
||||
```
|
||||
|
||||
When any tiki is marked done, this finds epics that block on it. If all of the epic's other dependencies are also done, the epic is completed automatically. This itself fires further after-update triggers, so cascade chains work naturally (up to the depth limit).
|
||||
|
||||
### Propagate cancellation
|
||||
|
||||
When a tiki is cancelled, cancel downstream tikis that haven't started:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "cancelled"
|
||||
update where id in blocks(old.id) and status in ["backlog", "ready"]
|
||||
set status="cancelled"
|
||||
```
|
||||
|
||||
Only tikis in `backlog` or `ready` are affected — in-progress work is not cancelled automatically.
|
||||
|
||||
### Run an external command
|
||||
|
||||
Trigger a script when a tiki enters a specific state:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "in progress" and "claude" in new.tags
|
||||
run("claude -p 'implement tiki " + old.id + "'")
|
||||
```
|
||||
|
||||
The `run()` action evaluates the expression to a command string, then executes it via `sh -c` with a 30-second timeout. Command failures are logged but do not block the mutation chain.
|
||||
|
||||
## Tips and gotchas
|
||||
|
||||
- Test your guard condition as a `select where ...` statement first. If the select returns unexpected results, the trigger will fire unexpectedly too.
|
||||
- Before-triggers are fail-closed. If the guard expression itself has a runtime error, the mutation is rejected. Keep guard logic straightforward.
|
||||
- Triggers that modify the same fields they guard on can cascade. For example, an after-update trigger that changes `status` will fire other after-update triggers. Design triggers to converge — avoid chains that cycle indefinitely. The cascade depth limit (8) prevents runaway loops, but silent termination is rarely what you want.
|
||||
- `run()` commands execute with the permissions of the tiki process. Treat the `ruki` field in `workflow.yaml` the same as any other executable configuration.
|
||||
- A parse error in any trigger definition prevents the app from starting. Validate your `workflow.yaml` before deploying.
|
||||
|
||||
---
|
||||
|
||||
## Execution pipeline
|
||||
|
||||
When a tiki is created, updated, or deleted, the mutation goes through this pipeline:
|
||||
|
||||
1. **Depth check** — reject if the trigger cascade depth exceeds the limit
|
||||
2. **Before-validators** — run all registered before-triggers for this event; collect rejections
|
||||
3. **Persist** — write the change to the store
|
||||
4. **After-hooks** — run all registered after-triggers for this event
|
||||
|
||||
Before-triggers are registered as mutation validators. They run before persistence and can block the mutation. After-triggers are registered as hooks. They run after persistence and cannot undo it.
|
||||
|
||||
All validators for a given event run — rejections are accumulated, not short-circuited. If any validator rejects, the mutation is blocked and none of the rejection messages are lost.
|
||||
|
||||
After-hooks run in definition order. Each hook's errors are logged but do not propagate — the original mutation is unaffected.
|
||||
|
||||
## Before-trigger behavior
|
||||
|
||||
Before-triggers use **fail-closed** semantics:
|
||||
|
||||
- If the guard condition matches, the mutation is rejected with the `deny` message.
|
||||
- If the guard condition evaluation itself errors (e.g. a runtime type error), the mutation is also rejected. This prevents bad triggers from silently allowing mutations they were meant to block.
|
||||
|
||||
The context provided to before-triggers depends on the event:
|
||||
|
||||
| Event | `old` | `new` | `allTasks` |
|
||||
|---|---|---|---|
|
||||
| create | nil | proposed task | stored tasks + proposed |
|
||||
| update | persisted (cloned) | proposed version | stored tasks with proposed applied |
|
||||
| delete | task being deleted | nil | current stored tasks |
|
||||
|
||||
For before-update triggers, `allTasks` reflects the **candidate state** — the proposed update is already applied in the task list. This matters for aggregate predicates like WIP limits using `count(select ...)`, which need to see the world as it would look after the update.
|
||||
|
||||
## After-trigger behavior
|
||||
|
||||
After-triggers use **fail-open** semantics:
|
||||
|
||||
- If the guard condition matches, the action executes.
|
||||
- If the guard condition evaluation itself errors, the trigger is skipped and the error is logged. The mutation chain continues.
|
||||
- If the action fails, the error is logged. The original mutation is not rolled back.
|
||||
|
||||
After-hooks read a fresh task list from the store each time they fire. This means cascaded triggers see the current state of the world, including changes made by earlier triggers in the chain.
|
||||
|
||||
After-triggers support two action forms:
|
||||
|
||||
- A CRUD action (`create`, `update`, or `delete`) — executed through the mutation gate, which fires its own triggers
|
||||
- A `run()` command — executed as a shell command (see [The run() action](#the-run-action))
|
||||
|
||||
## Cascade depth
|
||||
|
||||
After-triggers can cause further mutations, which fire their own triggers, and so on. To prevent infinite loops, cascade depth is tracked:
|
||||
|
||||
- The root mutation (user-initiated) runs at depth 0.
|
||||
- Each triggered mutation increments the depth by 1.
|
||||
- At depth >= 8, after-hooks are skipped with a warning log.
|
||||
- At depth > 8, the mutation gate rejects the mutation entirely.
|
||||
|
||||
The maximum cascade depth is **8**. Termination is graceful — a warning is logged, not a panic. Within a cascade, each after-hook reads the latest store state, so it sees changes from earlier triggers.
|
||||
|
||||
## The run() action
|
||||
|
||||
When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed:
|
||||
|
||||
- The command runs via `sh -c <command-string>`.
|
||||
- A **30-second timeout** is enforced. If the command exceeds it, the child process is killed.
|
||||
- Command failure (non-zero exit) is **logged** but does not block the mutation chain.
|
||||
- Command success is also logged.
|
||||
|
||||
The command string is dynamically evaluated from the trigger's expression, which may reference `old.id`, `new.status`, or other fields via string concatenation.
|
||||
|
||||
## Configuration discovery details
|
||||
|
||||
Trigger definitions are loaded from `workflow.yaml` using the standard [configuration precedence](../config.md#configuration-precedence). The **last file with a `triggers:` key wins**:
|
||||
|
||||
| File | `triggers:` key | Effect |
|
||||
|---|---|---|
|
||||
| user config | yes, 2 triggers | base: 2 triggers |
|
||||
| project config | absent | no override, user triggers survive |
|
||||
| cwd config | `triggers: []` | override: 0 triggers |
|
||||
|
||||
A file that exists but has no `triggers:` key expresses no opinion and does not override. An explicit empty list (`triggers: []`) is an active override that disables inherited triggers.
|
||||
|
||||
If two candidate paths resolve to the same absolute path (e.g. when the project root is the current directory), the file is read once.
|
||||
|
||||
## Startup and error handling
|
||||
|
||||
Triggers are loaded during application startup, after the store is initialized but before controllers are created.
|
||||
|
||||
- Each trigger definition is parsed with the ruki parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
|
||||
- If no `triggers:` section is found in any workflow file, zero triggers are loaded and the app starts normally.
|
||||
- Successfully loaded triggers are logged with a count at startup.
|
||||
91
config/triggers.go
Normal file
91
config/triggers.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TriggerDef represents a single trigger entry in workflow.yaml.
|
||||
type TriggerDef struct {
|
||||
Description string `yaml:"description"`
|
||||
Ruki string `yaml:"ruki"`
|
||||
}
|
||||
|
||||
// triggerFileData is the minimal YAML structure for reading triggers from workflow.yaml.
|
||||
type triggerFileData struct {
|
||||
Triggers []TriggerDef `yaml:"triggers"`
|
||||
}
|
||||
|
||||
// LoadTriggerDefs discovers and returns raw trigger definitions from workflow.yaml files.
|
||||
// Uses its own discovery path (not FindWorkflowFiles) to avoid the empty-views filter.
|
||||
// Override semantics: last file with a triggers: section wins (cwd > project > user).
|
||||
// An explicit empty list (triggers: []) overrides inherited triggers.
|
||||
// The caller is responsible for parsing each TriggerDef.Ruki with ruki.ParseTrigger.
|
||||
func LoadTriggerDefs() ([]TriggerDef, error) {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// candidate paths in discovery order: user config → project config → cwd
|
||||
candidates := []string{
|
||||
pm.UserConfigWorkflowFile(),
|
||||
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
|
||||
defaultWorkflowFilename, // relative to cwd
|
||||
}
|
||||
|
||||
var winningDefs []TriggerDef
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading triggers from %s: %w", path, err)
|
||||
}
|
||||
if found {
|
||||
// last file with a triggers: section wins
|
||||
winningDefs = defs
|
||||
}
|
||||
}
|
||||
|
||||
return winningDefs, nil
|
||||
}
|
||||
|
||||
// readTriggersFromFile reads a workflow.yaml and returns its triggers section.
|
||||
// Returns (defs, true, nil) if the file exists and has a triggers: key.
|
||||
// Returns (nil, false, nil) if the file doesn't exist or has no triggers: key.
|
||||
func readTriggersFromFile(path string) ([]TriggerDef, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// check whether the YAML contains a triggers: key at all
|
||||
// (absent section = no opinion, does not override)
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing YAML: %w", err)
|
||||
}
|
||||
if _, hasTriggers := raw["triggers"]; !hasTriggers {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
var tf triggerFileData
|
||||
if err := yaml.Unmarshal(data, &tf); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing triggers: %w", err)
|
||||
}
|
||||
|
||||
return tf.Triggers, true, nil
|
||||
}
|
||||
306
config/triggers_test.go
Normal file
306
config/triggers_test.go
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- readTriggersFromFile unit tests ---
|
||||
|
||||
func TestReadTriggersFromFile_NonExistent(t *testing.T) {
|
||||
defs, found, err := readTriggersFromFile("/no/such/file.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected found=false for missing file")
|
||||
}
|
||||
if defs != nil {
|
||||
t.Fatalf("expected nil defs, got %v", defs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_NoTriggersKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte("views:\n - name: board\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected found=false when triggers: key absent")
|
||||
}
|
||||
if defs != nil {
|
||||
t.Fatalf("expected nil defs, got %v", defs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_EmptyTriggersList(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte("triggers: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found=true when triggers: key present (even if empty)")
|
||||
}
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected 0 defs, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_WithTriggers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
content := `triggers:
|
||||
- description: "block done"
|
||||
ruki: 'before update where new.status = "done" deny "no"'
|
||||
- description: "auto-assign"
|
||||
ruki: 'after create where new.assignee is empty update where id = new.id set assignee="bot"'
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found=true")
|
||||
}
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("expected 2 defs, got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "block done" {
|
||||
t.Errorf("defs[0].Description = %q, want %q", defs[0].Description, "block done")
|
||||
}
|
||||
if defs[1].Description != "auto-assign" {
|
||||
t.Errorf("defs[1].Description = %q, want %q", defs[1].Description, "auto-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_InvalidYAML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte(":\ninvalid: [yaml\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := readTriggersFromFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
// --- LoadTriggerDefs precedence tests ---
|
||||
|
||||
// setupTriggerPrecedenceTest creates temp dirs for user, project, and cwd,
|
||||
// resets the PathManager, and returns a cleanup function.
|
||||
func setupTriggerPrecedenceTest(t *testing.T) (userTikiDir, projectDocDir, cwdDir string) {
|
||||
t.Helper()
|
||||
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
userTikiDir = filepath.Join(userDir, "tiki")
|
||||
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projectDir := t.TempDir()
|
||||
projectDocDir = filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(projectDocDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cwdDir = t.TempDir()
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
return userTikiDir, projectDocDir, cwdDir
|
||||
}
|
||||
|
||||
func writeTriggerFile(t *testing.T, dir, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_CwdOverridesProjectAndUser(t *testing.T) {
|
||||
userDir, projectDir, cwdDir := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
writeTriggerFile(t, projectDir, `triggers:
|
||||
- description: "project trigger"
|
||||
ruki: 'before update deny "project"'
|
||||
`)
|
||||
writeTriggerFile(t, cwdDir, `triggers:
|
||||
- description: "cwd trigger"
|
||||
ruki: 'before update deny "cwd"'
|
||||
`)
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (cwd wins), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "cwd trigger" {
|
||||
t.Errorf("expected cwd trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_ProjectOverridesUser(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
writeTriggerFile(t, projectDir, `triggers:
|
||||
- description: "project trigger"
|
||||
ruki: 'before update deny "project"'
|
||||
`)
|
||||
// no cwd workflow.yaml
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (project wins), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "project trigger" {
|
||||
t.Errorf("expected project trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_UserFallback(t *testing.T) {
|
||||
userDir, _, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
// no project or cwd workflow.yaml
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (user fallback), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "user trigger" {
|
||||
t.Errorf("expected user trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_EmptyListOverridesInherited(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
// project explicitly disables triggers with empty list
|
||||
writeTriggerFile(t, projectDir, "triggers: []\n")
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected 0 defs (empty list overrides user), got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_NoTriggersKeyDoesNotOverride(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
// project has workflow.yaml but no triggers: key — should not override
|
||||
writeTriggerFile(t, projectDir, "views:\n - name: board\n")
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (user preserved), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "user trigger" {
|
||||
t.Errorf("expected user trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_NoWorkflowFiles(t *testing.T) {
|
||||
_, _, _ = setupTriggerPrecedenceTest(t)
|
||||
// no workflow.yaml files anywhere
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected 0 defs, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_DeduplicatesAbsPath(t *testing.T) {
|
||||
// when project root == cwd, the project and cwd candidates resolve to the same file
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sharedDir := t.TempDir()
|
||||
docDir := filepath.Join(sharedDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(sharedDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = sharedDir
|
||||
|
||||
// write workflow.yaml in cwd (== project root's parent of .doc — but workflow.yaml is at cwd level)
|
||||
writeTriggerFile(t, sharedDir, `triggers:
|
||||
- description: "shared trigger"
|
||||
ruki: 'before update deny "shared"'
|
||||
`)
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// should find it once, not duplicated
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (deduped), got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
|
|
@ -194,7 +195,7 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
slog.Error("deps move: failed to apply action", "task_id", u.taskID, "error", err)
|
||||
return false
|
||||
}
|
||||
if err := dc.mutationGate.UpdateTask(updated); err != nil {
|
||||
if err := dc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("deps move: failed to update task", "task_id", u.taskID, "error", err)
|
||||
if dc.statusline != nil {
|
||||
dc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -167,7 +168,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
if err := pc.mutationGate.UpdateTask(updated); err != nil {
|
||||
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("failed to update task after plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
|
|
@ -208,7 +209,7 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
if err := pc.mutationGate.UpdateTask(updated); err != nil {
|
||||
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
|
|
@ -353,7 +354,7 @@ func (pb *pluginBase) handleDeleteTask(filteredTasks func(int) []*task.Task) boo
|
|||
if taskItem == nil {
|
||||
return false
|
||||
}
|
||||
if err := pb.mutationGate.DeleteTask(taskItem); err != nil {
|
||||
if err := pb.mutationGate.DeleteTask(context.Background(), taskItem); err != nil {
|
||||
slog.Error("failed to delete task", "task_id", taskID, "error", err)
|
||||
if pb.statusline != nil {
|
||||
pb.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
|
|
@ -111,7 +112,7 @@ func (tc *TaskController) CommitEditSession() error {
|
|||
if tc.draftTask != nil {
|
||||
setAuthorFromGit(tc.draftTask, tc.taskStore)
|
||||
|
||||
if err := tc.mutationGate.CreateTask(tc.draftTask); err != nil {
|
||||
if err := tc.mutationGate.CreateTask(context.Background(), tc.draftTask); err != nil {
|
||||
slog.Error("failed to create draft task", "error", err)
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
|
@ -134,7 +135,7 @@ func (tc *TaskController) CommitEditSession() error {
|
|||
// For now, proceed with save (last write wins)
|
||||
}
|
||||
|
||||
if err := tc.mutationGate.UpdateTask(tc.editingTask); err != nil {
|
||||
if err := tc.mutationGate.UpdateTask(context.Background(), tc.editingTask); err != nil {
|
||||
slog.Error("failed to update task", "taskID", tc.currentTaskID, "error", err)
|
||||
return fmt.Errorf("failed to update task: %w", err)
|
||||
}
|
||||
|
|
@ -424,7 +425,7 @@ func (tc *TaskController) SetFocusedField(field model.EditField) {
|
|||
|
||||
// UpdateTask persists changes to the specified task via the mutation gate.
|
||||
func (tc *TaskController) UpdateTask(task *taskpkg.Task) {
|
||||
_ = tc.mutationGate.UpdateTask(task)
|
||||
_ = tc.mutationGate.UpdateTask(context.Background(), task)
|
||||
}
|
||||
|
||||
// AddComment adds a new comment to the current task with the specified author and text.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/internal/app"
|
||||
"github.com/boolean-maybe/tiki/internal/background"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
|
|
@ -115,6 +116,17 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
syncHeaderPluginActions(headerConfig)
|
||||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
triggerCount, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName })
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load triggers: %w", err)
|
||||
}
|
||||
if triggerCount > 0 {
|
||||
slog.Info("triggers loaded", "count", triggerCount)
|
||||
}
|
||||
|
||||
// Phase 7: Application and controllers
|
||||
application := app.NewApp()
|
||||
app.SetupSignalHandler(application)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package pipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/internal/bootstrap"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
)
|
||||
|
||||
|
|
@ -93,6 +95,13 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
|
|||
}
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
// load triggers so piped creates fire them
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
if _, loadErr := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName }); loadErr != nil {
|
||||
return "", fmt.Errorf("load triggers: %w", loadErr)
|
||||
}
|
||||
|
||||
task, err := taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create task template: %w", err)
|
||||
|
|
@ -101,7 +110,7 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
|
|||
task.Title = title
|
||||
task.Description = description
|
||||
|
||||
if err := gate.CreateTask(task); err != nil {
|
||||
if err := gate.CreateTask(context.Background(), task); err != nil {
|
||||
return "", fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
|
@ -55,19 +56,21 @@ func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error
|
|||
return fmt.Errorf("execute: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch {
|
||||
case result.Select != nil:
|
||||
formatter := NewTableFormatter()
|
||||
return formatter.Format(out, result.Select)
|
||||
|
||||
case result.Update != nil:
|
||||
return persistAndSummarize(gate, result.Update, out)
|
||||
return persistAndSummarize(ctx, gate, result.Update, out)
|
||||
|
||||
case result.Create != nil:
|
||||
return persistCreate(gate, result.Create, template, out)
|
||||
return persistCreate(ctx, gate, result.Create, template, out)
|
||||
|
||||
case result.Delete != nil:
|
||||
return persistDelete(gate, result.Delete, out)
|
||||
return persistDelete(ctx, gate, result.Delete, out)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported statement type")
|
||||
|
|
@ -110,12 +113,12 @@ func RunSelectQuery(readStore store.ReadStore, query string, out io.Writer) erro
|
|||
return formatter.Format(out, result.Select)
|
||||
}
|
||||
|
||||
func persistAndSummarize(gate *service.TaskMutationGate, ur *ruki.UpdateResult, out io.Writer) error {
|
||||
func persistAndSummarize(ctx context.Context, gate *service.TaskMutationGate, ur *ruki.UpdateResult, out io.Writer) error {
|
||||
var succeeded, failed int
|
||||
var firstErr error
|
||||
|
||||
for _, t := range ur.Updated {
|
||||
if err := gate.UpdateTask(t); err != nil {
|
||||
if err := gate.UpdateTask(ctx, t); err != nil {
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
|
|
@ -134,13 +137,13 @@ func persistAndSummarize(gate *service.TaskMutationGate, ur *ruki.UpdateResult,
|
|||
return nil
|
||||
}
|
||||
|
||||
func persistCreate(gate *service.TaskMutationGate, cr *ruki.CreateResult, template *task.Task, out io.Writer) error {
|
||||
func persistCreate(ctx context.Context, gate *service.TaskMutationGate, cr *ruki.CreateResult, template *task.Task, out io.Writer) error {
|
||||
t := cr.Task
|
||||
t.ID = template.ID
|
||||
t.CreatedBy = template.CreatedBy
|
||||
t.CreatedAt = template.CreatedAt
|
||||
|
||||
if err := gate.CreateTask(t); err != nil {
|
||||
if err := gate.CreateTask(ctx, t); err != nil {
|
||||
return fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -148,11 +151,11 @@ func persistCreate(gate *service.TaskMutationGate, cr *ruki.CreateResult, templa
|
|||
return nil
|
||||
}
|
||||
|
||||
func persistDelete(gate *service.TaskMutationGate, dr *ruki.DeleteResult, out io.Writer) error {
|
||||
func persistDelete(ctx context.Context, gate *service.TaskMutationGate, dr *ruki.DeleteResult, out io.Writer) error {
|
||||
readStore := gate.ReadStore()
|
||||
var succeeded, failed int
|
||||
for _, t := range dr.Deleted {
|
||||
if err := gate.DeleteTask(t); err != nil {
|
||||
if err := gate.DeleteTask(ctx, t); err != nil {
|
||||
failed++
|
||||
} else if readStore.GetTask(t.ID) != nil {
|
||||
// store silently failed to delete
|
||||
|
|
|
|||
8
main.go
8
main.go
|
|
@ -230,6 +230,14 @@ func runExec(args []string) int {
|
|||
}
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
// load triggers so exec queries fire them
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
if _, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName }); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: load triggers: %v\n", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
if err := rukiRuntime.RunQuery(gate, args[0], os.Stdout); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitQueryError
|
||||
|
|
|
|||
|
|
@ -869,11 +869,16 @@ func (e *Executor) resolveComparisonType(left, right Expr) ValueType {
|
|||
}
|
||||
|
||||
func (e *Executor) exprFieldType(expr Expr) ValueType {
|
||||
fr, ok := expr.(*FieldRef)
|
||||
if !ok {
|
||||
var name string
|
||||
switch e := expr.(type) {
|
||||
case *FieldRef:
|
||||
name = e.Name
|
||||
case *QualifiedRef:
|
||||
name = e.Name
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
fs, ok := e.schema.Field(fr.Name)
|
||||
fs, ok := e.schema.Field(name)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,10 +45,11 @@ type FieldSpec struct {
|
|||
|
||||
// Parser parses ruki DSL statements and triggers.
|
||||
type Parser struct {
|
||||
stmtParser *participle.Parser[statementGrammar]
|
||||
triggerParser *participle.Parser[triggerGrammar]
|
||||
schema Schema
|
||||
qualifiers qualifierPolicy // set before each validation pass
|
||||
stmtParser *participle.Parser[statementGrammar]
|
||||
triggerParser *participle.Parser[triggerGrammar]
|
||||
schema Schema
|
||||
qualifiers qualifierPolicy // set before each validation pass
|
||||
requireQualifiers bool // when true, bare FieldRef is a parse error (trigger where-guards)
|
||||
}
|
||||
|
||||
// NewParser constructs a Parser with the given schema for validation.
|
||||
|
|
|
|||
503
ruki/trigger_executor.go
Normal file
503
ruki/trigger_executor.go
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TriggerExecutor evaluates trigger guards and actions in a trigger context.
|
||||
// It wraps Executor with old/new mutation context for QualifiedRef resolution.
|
||||
// A fresh Executor is created per call — no shared mutable state.
|
||||
type TriggerExecutor struct {
|
||||
schema Schema
|
||||
userFunc func() string
|
||||
}
|
||||
|
||||
// NewTriggerExecutor creates a TriggerExecutor.
|
||||
func NewTriggerExecutor(schema Schema, userFunc func() string) *TriggerExecutor {
|
||||
if userFunc == nil {
|
||||
userFunc = func() string { return "" }
|
||||
}
|
||||
return &TriggerExecutor{schema: schema, userFunc: userFunc}
|
||||
}
|
||||
|
||||
// TriggerContext holds the old/new task snapshots and allTasks for trigger evaluation.
|
||||
type TriggerContext struct {
|
||||
Old *task.Task // nil for create
|
||||
New *task.Task // nil for delete
|
||||
AllTasks []*task.Task
|
||||
}
|
||||
|
||||
// EvalGuard evaluates a trigger's Where condition against the triggering event.
|
||||
// Returns true if the trigger should fire (guard passes or no guard).
|
||||
func (te *TriggerExecutor) EvalGuard(trig *Trigger, tc *TriggerContext) (bool, error) {
|
||||
if trig.Where == nil {
|
||||
return true, nil
|
||||
}
|
||||
// the guard evaluates qualified refs against old/new directly;
|
||||
// there is no "current task" — we use a sentinel that QualifiedRef overrides
|
||||
sentinel := te.guardSentinel(tc)
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
return exec.evalCondition(trig.Where, sentinel, tc.AllTasks)
|
||||
}
|
||||
|
||||
// ExecAction executes a trigger's CRUD action statement and returns the result.
|
||||
// QualifiedRefs resolve against tc.Old/tc.New. Bare fields resolve against target tasks.
|
||||
// Returns *Result for persistence by service/.
|
||||
func (te *TriggerExecutor) ExecAction(trig *Trigger, tc *TriggerContext) (*Result, error) {
|
||||
if trig.Action == nil {
|
||||
return nil, fmt.Errorf("trigger has no action")
|
||||
}
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
return exec.Execute(trig.Action, tc.AllTasks)
|
||||
}
|
||||
|
||||
// ExecRun evaluates the run() command expression to a string against the trigger context.
|
||||
// Returns the command string for execution by service/.
|
||||
func (te *TriggerExecutor) ExecRun(trig *Trigger, tc *TriggerContext) (string, error) {
|
||||
if trig.Run == nil {
|
||||
return "", fmt.Errorf("trigger has no run action")
|
||||
}
|
||||
sentinel := te.guardSentinel(tc)
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
val, err := exec.evalExpr(trig.Run.Command, sentinel, tc.AllTasks)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("evaluating run command: %w", err)
|
||||
}
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("run command did not evaluate to string, got %T", val)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// guardSentinel returns the best "current task" for guard evaluation.
|
||||
// In guards, all references should be qualified (old./new.), but the executor
|
||||
// still needs a task to evaluate against. We prefer new (proposed) over old.
|
||||
func (te *TriggerExecutor) guardSentinel(tc *TriggerContext) *task.Task {
|
||||
if tc.New != nil {
|
||||
return tc.New
|
||||
}
|
||||
if tc.Old != nil {
|
||||
return tc.Old
|
||||
}
|
||||
return &task.Task{}
|
||||
}
|
||||
|
||||
// triggerExecOverride wraps Executor and intercepts QualifiedRef evaluation.
|
||||
type triggerExecOverride struct {
|
||||
*Executor
|
||||
tc *TriggerContext
|
||||
}
|
||||
|
||||
// newExecWithOverrides creates a fresh Executor with QualifiedRef interception.
|
||||
func (te *TriggerExecutor) newExecWithOverrides(tc *TriggerContext) *triggerExecOverride {
|
||||
return &triggerExecOverride{
|
||||
Executor: NewExecutor(te.schema, te.userFunc),
|
||||
tc: tc,
|
||||
}
|
||||
}
|
||||
|
||||
// evalExpr overrides the base Executor to handle QualifiedRef.
|
||||
func (e *triggerExecOverride) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
if qr, ok := expr.(*QualifiedRef); ok {
|
||||
return e.resolveQualifiedRef(qr)
|
||||
}
|
||||
// for non-QualifiedRef expressions, delegate to base but with our overridden evalExpr
|
||||
// for nested expressions
|
||||
return e.evalExprRecursive(expr, t, allTasks)
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) resolveQualifiedRef(qr *QualifiedRef) (interface{}, error) {
|
||||
switch qr.Qualifier {
|
||||
case "old":
|
||||
if e.tc.Old == nil {
|
||||
return nil, nil // old is nil for create events
|
||||
}
|
||||
return extractField(e.tc.Old, qr.Name), nil
|
||||
case "new":
|
||||
if e.tc.New == nil {
|
||||
return nil, nil // new is nil for delete events
|
||||
}
|
||||
return extractField(e.tc.New, qr.Name), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown qualifier %q", qr.Qualifier)
|
||||
}
|
||||
}
|
||||
|
||||
// evalExprRecursive handles all expression types, dispatching QualifiedRef
|
||||
// to resolveQualifiedRef and delegating everything else to the base Executor.
|
||||
func (e *triggerExecOverride) evalExprRecursive(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
switch expr := expr.(type) {
|
||||
case *QualifiedRef:
|
||||
return e.resolveQualifiedRef(expr)
|
||||
case *FieldRef:
|
||||
return extractField(t, expr.Name), nil
|
||||
case *BinaryExpr:
|
||||
leftVal, err := e.evalExpr(expr.Left, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rightVal, err := e.evalExpr(expr.Right, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch expr.Op {
|
||||
case "+":
|
||||
return addValues(leftVal, rightVal)
|
||||
case "-":
|
||||
return subtractValues(leftVal, rightVal)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown binary operator %q", expr.Op)
|
||||
}
|
||||
case *ListLiteral:
|
||||
result := make([]interface{}, len(expr.Elements))
|
||||
for i, elem := range expr.Elements {
|
||||
val, err := e.evalExpr(elem, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = val
|
||||
}
|
||||
return result, nil
|
||||
case *FunctionCall:
|
||||
return e.evalFunctionCallOverride(expr, t, allTasks)
|
||||
default:
|
||||
// StringLiteral, IntLiteral, DateLiteral, DurationLiteral, EmptyLiteral, SubQuery
|
||||
return e.Executor.evalExpr(expr, t, allTasks)
|
||||
}
|
||||
}
|
||||
|
||||
// evalCondition overrides the base to use our evalExpr for expression evaluation.
|
||||
func (e *triggerExecOverride) evalCondition(c Condition, t *task.Task, allTasks []*task.Task) (bool, error) {
|
||||
switch c := c.(type) {
|
||||
case *BinaryCondition:
|
||||
left, err := e.evalCondition(c.Left, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch c.Op {
|
||||
case "and":
|
||||
if !left {
|
||||
return false, nil
|
||||
}
|
||||
return e.evalCondition(c.Right, t, allTasks)
|
||||
case "or":
|
||||
if left {
|
||||
return true, nil
|
||||
}
|
||||
return e.evalCondition(c.Right, t, allTasks)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown binary operator %q", c.Op)
|
||||
}
|
||||
case *NotCondition:
|
||||
val, err := e.evalCondition(c.Inner, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !val, nil
|
||||
case *CompareExpr:
|
||||
leftVal, err := e.evalExpr(c.Left, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rightVal, err := e.evalExpr(c.Right, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return e.compareValues(leftVal, rightVal, c.Op, c.Left, c.Right)
|
||||
case *IsEmptyExpr:
|
||||
val, err := e.evalExpr(c.Expr, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
empty := isZeroValue(val)
|
||||
if c.Negated {
|
||||
return !empty, nil
|
||||
}
|
||||
return empty, nil
|
||||
case *InExpr:
|
||||
return e.evalInOverride(c, t, allTasks)
|
||||
case *QuantifierExpr:
|
||||
return e.evalQuantifierOverride(c, t, allTasks)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown condition type %T", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalInOverride(c *InExpr, t *task.Task, allTasks []*task.Task) (bool, error) {
|
||||
val, err := e.evalExpr(c.Value, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
collVal, err := e.evalExpr(c.Collection, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if list, ok := collVal.([]interface{}); ok {
|
||||
valStr := normalizeToString(val)
|
||||
found := false
|
||||
for _, elem := range list {
|
||||
if normalizeToString(elem) == valStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.Negated {
|
||||
return !found, nil
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
if haystack, ok := collVal.(string); ok {
|
||||
needle, ok := val.(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("in: substring check requires string value")
|
||||
}
|
||||
found := strings.Contains(haystack, needle)
|
||||
if c.Negated {
|
||||
return !found, nil
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("in: collection is not a list or string")
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalQuantifierOverride(q *QuantifierExpr, t *task.Task, allTasks []*task.Task) (bool, error) {
|
||||
listVal, err := e.evalExpr(q.Expr, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
refs, ok := listVal.([]interface{})
|
||||
if !ok {
|
||||
return false, fmt.Errorf("quantifier: expression is not a list")
|
||||
}
|
||||
|
||||
refTasks := resolveRefTasks(refs, allTasks)
|
||||
|
||||
switch q.Kind {
|
||||
case "any":
|
||||
for _, rt := range refTasks {
|
||||
match, err := e.evalCondition(q.Condition, rt, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if match {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
case "all":
|
||||
if len(refTasks) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
for _, rt := range refTasks {
|
||||
match, err := e.evalCondition(q.Condition, rt, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !match {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown quantifier %q", q.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalFunctionCallOverride(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
switch fc.Name {
|
||||
case "count":
|
||||
return e.evalCountOverride(fc, allTasks)
|
||||
case "blocks":
|
||||
return e.evalBlocksOverride(fc, t, allTasks)
|
||||
case "next_date":
|
||||
return e.evalNextDateOverride(fc, t, allTasks)
|
||||
default:
|
||||
// now, user, call — delegate to base (no expression args needing QualifiedRef resolution)
|
||||
return e.evalFunctionCall(fc, t, allTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalCountOverride(fc *FunctionCall, allTasks []*task.Task) (interface{}, error) {
|
||||
sq, ok := fc.Args[0].(*SubQuery)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("count() argument must be a select subquery")
|
||||
}
|
||||
if sq.Where == nil {
|
||||
return len(allTasks), nil
|
||||
}
|
||||
count := 0
|
||||
for _, t := range allTasks {
|
||||
match, err := e.evalCondition(sq.Where, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalBlocksOverride(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
val, err := e.evalExpr(fc.Args[0], t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blocksLookup(val, allTasks), nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalNextDateOverride(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
val, err := e.evalExpr(fc.Args[0], t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, ok := val.(task.Recurrence)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("next_date() argument must be a recurrence value")
|
||||
}
|
||||
return task.NextOccurrence(rec), nil
|
||||
}
|
||||
|
||||
// Execute overrides the base Executor to use our evalExpr/evalCondition.
|
||||
func (e *triggerExecOverride) Execute(stmt *Statement, tasks []*task.Task) (*Result, error) {
|
||||
if stmt == nil {
|
||||
return nil, fmt.Errorf("nil statement")
|
||||
}
|
||||
switch {
|
||||
case stmt.Create != nil:
|
||||
return e.executeCreate(stmt.Create, tasks)
|
||||
case stmt.Update != nil:
|
||||
return e.executeUpdate(stmt.Update, tasks)
|
||||
case stmt.Delete != nil:
|
||||
return e.executeDelete(stmt.Delete, tasks)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger action type")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result, error) {
|
||||
matched, err := e.filterTasks(upd.Where, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clones := make([]*task.Task, len(matched))
|
||||
for i, t := range matched {
|
||||
clones[i] = t.Clone()
|
||||
}
|
||||
|
||||
for _, clone := range clones {
|
||||
for _, a := range upd.Set {
|
||||
val, err := e.evalExpr(a.Value, clone, tasks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
if err := e.setField(clone, a.Field, val); err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{Update: &UpdateResult{Updated: clones}}, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) executeCreate(cr *CreateStmt, tasks []*task.Task) (*Result, error) {
|
||||
t := &task.Task{}
|
||||
for _, a := range cr.Assignments {
|
||||
val, err := e.evalExpr(a.Value, t, tasks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
if err := e.setField(t, a.Field, val); err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
}
|
||||
return &Result{Create: &CreateResult{Task: t}}, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) executeDelete(del *DeleteStmt, tasks []*task.Task) (*Result, error) {
|
||||
matched, err := e.filterTasks(del.Where, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{Delete: &DeleteResult{Deleted: matched}}, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) filterTasks(where Condition, tasks []*task.Task) ([]*task.Task, error) {
|
||||
if where == nil {
|
||||
result := make([]*task.Task, len(tasks))
|
||||
copy(result, tasks)
|
||||
return result, nil
|
||||
}
|
||||
var result []*task.Task
|
||||
for _, t := range tasks {
|
||||
match, err := e.evalCondition(where, t, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveRefTasks finds tasks by ID from a list of ref values.
|
||||
func resolveRefTasks(refs []interface{}, allTasks []*task.Task) []*task.Task {
|
||||
result := make([]*task.Task, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
refID := normalizeToString(ref)
|
||||
for _, at := range allTasks {
|
||||
if equalFoldID(at.ID, refID) {
|
||||
result = append(result, at)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// equalFoldID compares two task IDs case-insensitively.
|
||||
func equalFoldID(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
ca, cb := a[i], b[i]
|
||||
if ca >= 'a' && ca <= 'z' {
|
||||
ca -= 32
|
||||
}
|
||||
if cb >= 'a' && cb <= 'z' {
|
||||
cb -= 32
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// blocksLookup finds all task IDs that have the given ID in their dependsOn.
|
||||
func blocksLookup(val interface{}, allTasks []*task.Task) []interface{} {
|
||||
targetID := normalizeToString(val)
|
||||
var blockers []interface{}
|
||||
for _, at := range allTasks {
|
||||
for _, dep := range at.DependsOn {
|
||||
if equalFoldID(dep, targetID) {
|
||||
blockers = append(blockers, at.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if blockers == nil {
|
||||
blockers = []interface{}{}
|
||||
}
|
||||
return blockers
|
||||
}
|
||||
643
ruki/trigger_executor_test.go
Normal file
643
ruki/trigger_executor_test.go
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func newTestTriggerExecutor() *TriggerExecutor {
|
||||
return NewTriggerExecutor(testSchema{}, func() string { return "alice" })
|
||||
}
|
||||
|
||||
// --- EvalGuard ---
|
||||
|
||||
func TestEvalGuard_NoWhere(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
trig := &Trigger{Timing: "before", Event: "update"}
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "in_progress"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "done"},
|
||||
}
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard with no where should pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuard_QualifiedRefMatch(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where old.status = "in_progress" and new.status = "done" deny "no skip"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "in_progress"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "done"},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard should match: old is in_progress, new is done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuard_QualifiedRefNoMatch(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where old.status = "in_progress" and new.status = "done" deny "no"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "ready"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "in_progress"},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("guard should not match: old.status is ready, not in_progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuard_OldNilForCreate(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
// guard references old.priority — old is nil for creates, so old.priority resolves to nil
|
||||
trig, err := p.ParseTrigger(`before create where new.type = "story" and new.description is empty deny "needs description"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: nil, // create — no old
|
||||
New: &task.Task{ID: "TIKI-000001", Type: "story", Description: ""},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard should match: new type is story and description is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuard_QuantifierWithQualifiedCollection(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where new.status = "done" and new.dependsOn any status != "done" deny "open deps"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
dep := &task.Task{ID: "TIKI-DEP001", Status: "in_progress"}
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "in_progress", DependsOn: []string{"TIKI-DEP001"}},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "done", DependsOn: []string{"TIKI-DEP001"}},
|
||||
AllTasks: []*task.Task{
|
||||
{ID: "TIKI-000001", Status: "done", DependsOn: []string{"TIKI-DEP001"}},
|
||||
dep,
|
||||
},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard should match: new.status=done and dep status != done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuard_CountSubquery(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "ready", Assignee: "alice"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "in_progress", Assignee: "alice"},
|
||||
AllTasks: []*task.Task{
|
||||
{ID: "TIKI-000001", Status: "in_progress", Assignee: "alice"},
|
||||
{ID: "TIKI-000002", Status: "in_progress", Assignee: "alice"},
|
||||
{ID: "TIKI-000003", Status: "in_progress", Assignee: "alice"},
|
||||
{ID: "TIKI-000004", Status: "in_progress", Assignee: "bob"},
|
||||
},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard should match: 3 in-progress tasks for alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalGuard_CountSubqueryBelowLimit(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "ready", Assignee: "alice"},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "in_progress", Assignee: "alice"},
|
||||
AllTasks: []*task.Task{
|
||||
{ID: "TIKI-000001", Status: "in_progress", Assignee: "alice"},
|
||||
{ID: "TIKI-000002", Status: "in_progress", Assignee: "alice"},
|
||||
{ID: "TIKI-000003", Status: "ready", Assignee: "alice"},
|
||||
},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("guard should not match: only 2 in-progress tasks for alice")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ExecAction ---
|
||||
|
||||
func TestExecAction_UpdateWithQualifiedRefs(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
// after create: auto-assign urgent tasks
|
||||
trig, err := p.ParseTrigger(`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
newTask := &task.Task{ID: "TIKI-000001", Title: "Urgent", Status: "ready", Priority: 1}
|
||||
tc := &TriggerContext{
|
||||
Old: nil,
|
||||
New: newTask,
|
||||
AllTasks: []*task.Task{
|
||||
newTask,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Update == nil {
|
||||
t.Fatal("expected Update result")
|
||||
}
|
||||
if len(result.Update.Updated) != 1 {
|
||||
t.Fatalf("expected 1 updated task, got %d", len(result.Update.Updated))
|
||||
}
|
||||
if result.Update.Updated[0].Assignee != "booleanmaybe" {
|
||||
t.Fatalf("expected assignee=booleanmaybe, got %q", result.Update.Updated[0].Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAction_CreateWithQualifiedRefs(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
// after update: create recurring task
|
||||
trig, err := p.ParseTrigger(`after update where new.status = "done" and old.recurrence is not empty create title=old.title priority=old.priority tags=old.tags status="ready"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
old := &task.Task{ID: "TIKI-000001", Title: "Daily standup", Status: "in_progress", Priority: 2, Tags: []string{"meeting"}}
|
||||
new := &task.Task{ID: "TIKI-000001", Title: "Daily standup", Status: "done", Priority: 2}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: old,
|
||||
New: new,
|
||||
AllTasks: []*task.Task{new},
|
||||
}
|
||||
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Create == nil {
|
||||
t.Fatal("expected Create result")
|
||||
}
|
||||
created := result.Create.Task
|
||||
if created.Title != "Daily standup" {
|
||||
t.Fatalf("expected title 'Daily standup', got %q", created.Title)
|
||||
}
|
||||
if created.Priority != 2 {
|
||||
t.Fatalf("expected priority 2, got %d", created.Priority)
|
||||
}
|
||||
if created.Status != "ready" {
|
||||
t.Fatalf("expected status ready, got %q", created.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAction_DeleteWithQualifiedRefs(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`after update where new.status = "done" delete where id = old.id`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
old := &task.Task{ID: "TIKI-000001", Title: "Stale", Status: "in_progress"}
|
||||
new := &task.Task{ID: "TIKI-000001", Title: "Stale", Status: "done"}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: old,
|
||||
New: new,
|
||||
AllTasks: []*task.Task{new, {ID: "TIKI-000002", Title: "Other", Status: "ready"}},
|
||||
}
|
||||
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Delete == nil {
|
||||
t.Fatal("expected Delete result")
|
||||
}
|
||||
if len(result.Delete.Deleted) != 1 {
|
||||
t.Fatalf("expected 1 deleted, got %d", len(result.Delete.Deleted))
|
||||
}
|
||||
if result.Delete.Deleted[0].ID != "TIKI-000001" {
|
||||
t.Fatalf("expected deleted TIKI-000001, got %s", result.Delete.Deleted[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAction_CascadeEpicCompletion(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
// cascade: when a story completes, complete parent epics if all deps done
|
||||
trig, err := p.ParseTrigger(`after update where new.status = "done" update where id in blocks(old.id) and type = "epic" and dependsOn all status = "done" set status="done"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
story := &task.Task{ID: "TIKI-STORY1", Title: "Story", Status: "done", Type: "story"}
|
||||
epic := &task.Task{
|
||||
ID: "TIKI-EPIC01", Title: "Epic", Status: "in_progress", Type: "epic",
|
||||
DependsOn: []string{"TIKI-STORY1"},
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-STORY1", Status: "in_progress"},
|
||||
New: story,
|
||||
AllTasks: []*task.Task{story, epic},
|
||||
}
|
||||
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Update == nil {
|
||||
t.Fatal("expected Update result")
|
||||
}
|
||||
if len(result.Update.Updated) != 1 {
|
||||
t.Fatalf("expected 1 updated, got %d", len(result.Update.Updated))
|
||||
}
|
||||
if result.Update.Updated[0].Status != "done" {
|
||||
t.Fatalf("expected epic status done, got %q", result.Update.Updated[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAction_CleanupDependsOnDelete(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
deleted := &task.Task{ID: "TIKI-DEL001", Title: "Deleted"}
|
||||
downstream := &task.Task{
|
||||
ID: "TIKI-DOWN01", Title: "Downstream", Status: "ready",
|
||||
DependsOn: []string{"TIKI-DEL001", "TIKI-OTHER1"},
|
||||
}
|
||||
unrelated := &task.Task{
|
||||
ID: "TIKI-OTHER1", Title: "Unrelated", Status: "done",
|
||||
DependsOn: []string{"TIKI-OTHER2"},
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: deleted,
|
||||
New: nil, // delete event
|
||||
AllTasks: []*task.Task{downstream, unrelated},
|
||||
}
|
||||
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Update == nil {
|
||||
t.Fatal("expected Update result")
|
||||
}
|
||||
if len(result.Update.Updated) != 1 {
|
||||
t.Fatalf("expected 1 updated (downstream only), got %d", len(result.Update.Updated))
|
||||
}
|
||||
updated := result.Update.Updated[0]
|
||||
if len(updated.DependsOn) != 1 || updated.DependsOn[0] != "TIKI-OTHER1" {
|
||||
t.Fatalf("expected dependsOn=[TIKI-OTHER1], got %v", updated.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAction_NoAction(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
trig := &Trigger{Timing: "before", Event: "update"}
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001"},
|
||||
New: &task.Task{ID: "TIKI-000001"},
|
||||
}
|
||||
_, err := te.ExecAction(trig, tc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for trigger with no action")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ExecRun ---
|
||||
|
||||
func TestExecRun_SimpleString(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Status: "ready", Tags: []string{"claude"}},
|
||||
New: &task.Task{ID: "TIKI-000001", Status: "in_progress", Tags: []string{"claude"}},
|
||||
}
|
||||
|
||||
cmd, err := te.ExecRun(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := "claude -p 'implement tiki TIKI-000001'"
|
||||
if cmd != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRun_NoRunAction(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
trig := &Trigger{Timing: "after", Event: "update"}
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001"},
|
||||
New: &task.Task{ID: "TIKI-000001"},
|
||||
}
|
||||
_, err := te.ExecRun(trig, tc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for trigger with no run action")
|
||||
}
|
||||
}
|
||||
|
||||
// --- two-layer scoping: bare fields resolve to target, qualified to triggering ---
|
||||
|
||||
func TestExecAction_TwoLayerScoping(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
// "update where id = new.id set tags=new.tags + ["auto"]"
|
||||
// In the action's set clause: new.tags resolves from tc.New (triggering task)
|
||||
// but `tags` in set context resolves from the target task clone
|
||||
trig, err := p.ParseTrigger(`after create where new.type = "bug" update where id = new.id set tags=new.tags + ["needs-triage"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
newTask := &task.Task{ID: "TIKI-BUG001", Title: "Bug", Status: "ready", Type: "bug", Tags: []string{"urgent"}}
|
||||
tc := &TriggerContext{
|
||||
Old: nil,
|
||||
New: newTask,
|
||||
AllTasks: []*task.Task{
|
||||
newTask,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Update == nil {
|
||||
t.Fatal("expected Update result")
|
||||
}
|
||||
updated := result.Update.Updated[0]
|
||||
// new.tags is ["urgent"] from the triggering context, + ["needs-triage"]
|
||||
if len(updated.Tags) != 2 || updated.Tags[0] != "urgent" || updated.Tags[1] != "needs-triage" {
|
||||
t.Fatalf("expected tags [urgent, needs-triage], got %v", updated.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helper tests ---
|
||||
|
||||
func TestEqualFoldID(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"TIKI-000001", "tiki-000001", true},
|
||||
{"TIKI-000001", "TIKI-000001", true},
|
||||
{"TIKI-000001", "TIKI-000002", false},
|
||||
{"AB", "ABC", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := equalFoldID(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("equalFoldID(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocksLookup(t *testing.T) {
|
||||
tasks := []*task.Task{
|
||||
{ID: "TIKI-AAA001", DependsOn: []string{"TIKI-TARGET"}},
|
||||
{ID: "TIKI-AAA002", DependsOn: []string{"TIKI-OTHER"}},
|
||||
{ID: "TIKI-AAA003", DependsOn: []string{"TIKI-TARGET", "TIKI-OTHER"}},
|
||||
}
|
||||
blockers := blocksLookup("TIKI-TARGET", tasks)
|
||||
if len(blockers) != 2 {
|
||||
t.Fatalf("expected 2 blockers, got %d", len(blockers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRefTasks(t *testing.T) {
|
||||
tasks := []*task.Task{
|
||||
{ID: "TIKI-000001"},
|
||||
{ID: "TIKI-000002"},
|
||||
{ID: "TIKI-000003"},
|
||||
}
|
||||
refs := []interface{}{"TIKI-000001", "TIKI-000003", "TIKI-NOPE00"}
|
||||
resolved := resolveRefTasks(refs, tasks)
|
||||
if len(resolved) != 2 {
|
||||
t.Fatalf("expected 2 resolved tasks, got %d", len(resolved))
|
||||
}
|
||||
if resolved[0].ID != "TIKI-000001" || resolved[1].ID != "TIKI-000003" {
|
||||
t.Fatalf("expected TIKI-000001 and TIKI-000003, got %s and %s", resolved[0].ID, resolved[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// --- in expression override with string contains ---
|
||||
|
||||
func TestExecAction_NextDateWithQualifiedRef(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`after update where new.status = "done" and old.recurrence is not empty create title=old.title status="ready" type=old.type priority=old.priority due=next_date(old.recurrence)`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
old := &task.Task{ID: "TIKI-000001", Title: "Daily standup", Status: "in_progress", Type: "story", Priority: 3, Recurrence: task.RecurrenceDaily}
|
||||
new := &task.Task{ID: "TIKI-000001", Title: "Daily standup", Status: "done", Type: "story", Priority: 3}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: old,
|
||||
New: new,
|
||||
AllTasks: []*task.Task{new},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Create == nil {
|
||||
t.Fatal("expected Create result")
|
||||
}
|
||||
created := result.Create.Task
|
||||
if created.Due.IsZero() {
|
||||
t.Fatal("expected non-zero due date from next_date(old.recurrence)")
|
||||
}
|
||||
expBefore := task.NextOccurrenceFrom(task.RecurrenceDaily, before)
|
||||
expAfter := task.NextOccurrenceFrom(task.RecurrenceDaily, after)
|
||||
if !created.Due.Equal(expBefore) && !created.Due.Equal(expAfter) {
|
||||
t.Fatalf("expected due=%v or %v, got %v", expBefore, expAfter, created.Due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecAction_NextDateWithNewQualifiedRef(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`after update where new.status = "ready" and new.recurrence is not empty update where id = new.id set due=next_date(new.recurrence)`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
old := &task.Task{ID: "TIKI-000001", Title: "Recurring", Status: "done", Type: "story", Priority: 3}
|
||||
new := &task.Task{ID: "TIKI-000001", Title: "Recurring", Status: "ready", Type: "story", Priority: 3, Recurrence: task.RecurrenceDaily}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: old,
|
||||
New: new,
|
||||
AllTasks: []*task.Task{new},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := te.ExecAction(trig, tc)
|
||||
after := time.Now()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Update == nil {
|
||||
t.Fatal("expected Update result")
|
||||
}
|
||||
if len(result.Update.Updated) != 1 {
|
||||
t.Fatalf("expected 1 updated task, got %d", len(result.Update.Updated))
|
||||
}
|
||||
updated := result.Update.Updated[0]
|
||||
if updated.Due.IsZero() {
|
||||
t.Fatal("expected non-zero due date from next_date(new.recurrence)")
|
||||
}
|
||||
expBefore := task.NextOccurrenceFrom(task.RecurrenceDaily, before)
|
||||
expAfter := task.NextOccurrenceFrom(task.RecurrenceDaily, after)
|
||||
if !updated.Due.Equal(expBefore) && !updated.Due.Equal(expAfter) {
|
||||
t.Fatalf("expected due=%v or %v, got %v", expBefore, expAfter, updated.Due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalInOverride_SubstringMatch(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where "claude" in new.title deny "no claude"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Title: "implement claude feature"},
|
||||
New: &task.Task{ID: "TIKI-000001", Title: "implement claude feature"},
|
||||
}
|
||||
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard should match: 'claude' is in new.title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalInOverride_ListMembership(t *testing.T) {
|
||||
te := newTestTriggerExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
trig, err := p.ParseTrigger(`before update where "claude" in new.tags deny "no claude"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
// match
|
||||
tc := &TriggerContext{
|
||||
Old: &task.Task{ID: "TIKI-000001", Tags: []string{"claude", "ai"}},
|
||||
New: &task.Task{ID: "TIKI-000001", Tags: []string{"claude", "ai"}},
|
||||
}
|
||||
ok, err := te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("guard should match: 'claude' is in new.tags")
|
||||
}
|
||||
|
||||
// no match
|
||||
tc.New = &task.Task{ID: "TIKI-000001", Tags: []string{"ai"}}
|
||||
ok, err = te.EvalGuard(trig, tc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("guard should not match: 'claude' not in new.tags")
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ func TestParseTrigger_BeforeDeny(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
"block completion with open deps",
|
||||
`before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete task with open dependencies"`,
|
||||
`before update where new.status = "done" and new.dependsOn any status != "done" deny "cannot complete task with open dependencies"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
|
|
@ -288,3 +288,67 @@ func TestParseTrigger_NoWhereGuard(t *testing.T) {
|
|||
t.Fatal("expected Update action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_BareFieldInGuard_Rejected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
"bare field in comparison",
|
||||
`before update where status = "done" deny "no"`,
|
||||
},
|
||||
{
|
||||
"bare field in quantifier collection",
|
||||
`before update where dependsOn any status = "done" deny "no"`,
|
||||
},
|
||||
{
|
||||
"bare field in is empty",
|
||||
`before create where description is empty deny "need description"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseTrigger(tt.input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare field in trigger guard")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_BareFieldInsideQuantifier_Allowed(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
// bare status inside quantifier body is OK (zone 3), even within a trigger guard
|
||||
input := `before update where new.status = "done" and new.dependsOn all status != "done" deny "open deps"`
|
||||
_, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success for bare field inside quantifier body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_BareFieldInsideSubquery_Allowed(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
// bare fields inside count(select where ...) are OK (zone 4), qualifiers also OK
|
||||
input := `before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit"`
|
||||
_, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success for bare field inside subquery: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_QualifierInQuantifierBody_Rejected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
// qualifiers inside quantifier bodies are forbidden (zone 3)
|
||||
input := `before update where new.dependsOn all old.status = "done" deny "no"`
|
||||
_, err := p.ParseTrigger(input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for qualifier inside quantifier body")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,12 +90,17 @@ func (p *Parser) validateTrigger(t *Trigger) error {
|
|||
}
|
||||
}
|
||||
|
||||
// zone 1: trigger where-guard requires qualifiers
|
||||
if t.Where != nil {
|
||||
if err := p.validateCondition(t.Where); err != nil {
|
||||
p.requireQualifiers = true
|
||||
err := p.validateCondition(t.Where)
|
||||
p.requireQualifiers = false
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// zone 2: action statement — bare fields resolve against target task
|
||||
if t.Action != nil {
|
||||
if t.Action.Select != nil {
|
||||
return fmt.Errorf("trigger action must not be select")
|
||||
|
|
@ -301,10 +306,15 @@ func (p *Parser) validateQuantifier(q *QuantifierExpr) error {
|
|||
if exprType != ValueListRef {
|
||||
return fmt.Errorf("quantifier %s requires list<ref>, got %s", q.Kind, typeName(exprType))
|
||||
}
|
||||
saved := p.qualifiers
|
||||
// zone 3: quantifier bodies — bare fields refer to each related task,
|
||||
// qualifiers and requireQualifiers are both reset for the body
|
||||
savedQualifiers := p.qualifiers
|
||||
savedRequire := p.requireQualifiers
|
||||
p.qualifiers = noQualifiers
|
||||
p.requireQualifiers = false
|
||||
err = p.validateCondition(q.Condition)
|
||||
p.qualifiers = saved
|
||||
p.qualifiers = savedQualifiers
|
||||
p.requireQualifiers = savedRequire
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -313,6 +323,9 @@ func (p *Parser) validateQuantifier(q *QuantifierExpr) error {
|
|||
func (p *Parser) inferExprType(e Expr) (ValueType, error) {
|
||||
switch e := e.(type) {
|
||||
case *FieldRef:
|
||||
if p.requireQualifiers {
|
||||
return 0, fmt.Errorf("bare field %q not allowed in trigger guard — use old.%s or new.%s", e.Name, e.Name, e.Name)
|
||||
}
|
||||
fs, ok := p.schema.Field(e.Name)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown field %q", e.Name)
|
||||
|
|
@ -426,7 +439,13 @@ func (p *Parser) inferFuncCallType(fc *FunctionCall) (ValueType, error) {
|
|||
return 0, fmt.Errorf("count() argument must be a select subquery")
|
||||
}
|
||||
if sq.Where != nil {
|
||||
if err := p.validateCondition(sq.Where); err != nil {
|
||||
// zone 4: subquery bodies — bare fields refer to each candidate task,
|
||||
// qualifiers stay allowed (e.g. assignee = new.assignee), but requireQualifiers is reset
|
||||
savedRequire := p.requireQualifiers
|
||||
p.requireQualifiers = false
|
||||
err := p.validateCondition(sq.Where)
|
||||
p.requireQualifiers = savedRequire
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count() subquery: %w", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1209,7 +1209,7 @@ func TestValidation_QuantifierNoQualifiers(t *testing.T) {
|
|||
p := newTestParser()
|
||||
|
||||
// old. inside quantifier body should be rejected even in update trigger
|
||||
_, err := p.ParseTrigger(`before update where dependsOn any old.status = "done" deny "blocked"`)
|
||||
_, err := p.ParseTrigger(`before update where new.dependsOn any old.status = "done" deny "blocked"`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for old. in quantifier, got nil")
|
||||
}
|
||||
|
|
@ -1218,7 +1218,7 @@ func TestValidation_QuantifierNoQualifiers(t *testing.T) {
|
|||
}
|
||||
|
||||
// new. inside quantifier body should also be rejected
|
||||
_, err = p.ParseTrigger(`before update where dependsOn any new.status = "done" deny "blocked"`)
|
||||
_, err = p.ParseTrigger(`before update where new.dependsOn any new.status = "done" deny "blocked"`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for new. in quantifier, got nil")
|
||||
}
|
||||
|
|
@ -1227,7 +1227,7 @@ func TestValidation_QuantifierNoQualifiers(t *testing.T) {
|
|||
}
|
||||
|
||||
// unqualified field inside quantifier should still work
|
||||
_, err = p.ParseTrigger(`before update where dependsOn any status = "done" deny "blocked"`)
|
||||
_, err = p.ParseTrigger(`before update where new.dependsOn any status = "done" deny "blocked"`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -9,6 +11,30 @@ import (
|
|||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// triggerDepthKey is the context key for tracking trigger cascade depth.
|
||||
type triggerDepthKey struct{}
|
||||
|
||||
// triggerDepth returns the current trigger cascade depth from the context.
|
||||
// Returns 0 if no depth has been set (root mutation) or if ctx is nil.
|
||||
func triggerDepth(ctx context.Context) int {
|
||||
if ctx == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := ctx.Value(triggerDepthKey{}).(int); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// withTriggerDepth returns a derived context with the given trigger cascade depth.
|
||||
// Falls back to context.Background() if ctx is nil.
|
||||
func withTriggerDepth(ctx context.Context, depth int) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, triggerDepthKey{}, depth)
|
||||
}
|
||||
|
||||
// Rejection is returned by a validator to deny a mutation.
|
||||
type Rejection struct {
|
||||
Reason string
|
||||
|
|
@ -30,17 +56,29 @@ func (e *RejectionError) Error() string {
|
|||
return "validation failed: " + strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// MutationValidator inspects a task and optionally rejects the mutation.
|
||||
type MutationValidator func(t *task.Task) *Rejection
|
||||
// MutationValidator inspects a mutation and optionally rejects it.
|
||||
// For create: old=nil, new=proposed task.
|
||||
// For update: old=current persisted version (cloned), new=proposed version.
|
||||
// For delete: old=task being deleted, new=nil.
|
||||
type MutationValidator func(old, new *task.Task, allTasks []*task.Task) *Rejection
|
||||
|
||||
// AfterHook runs after a successful mutation for side effects (e.g. trigger cascades).
|
||||
// Hooks receive the context (with trigger depth), old and new task snapshots.
|
||||
// Errors are logged but do not propagate — the original mutation is not affected.
|
||||
type AfterHook func(ctx context.Context, old, new *task.Task) error
|
||||
|
||||
// TaskMutationGate is the single gateway for all task mutations.
|
||||
// All Create/Update/Delete/AddComment operations must go through this gate.
|
||||
// Validators are registered per operation type and run before persistence.
|
||||
// After-hooks run post-persist for side effects; their errors are logged, not propagated.
|
||||
type TaskMutationGate struct {
|
||||
store store.Store
|
||||
createValidators []MutationValidator
|
||||
updateValidators []MutationValidator
|
||||
deleteValidators []MutationValidator
|
||||
afterCreateHooks []AfterHook
|
||||
afterUpdateHooks []AfterHook
|
||||
afterDeleteHooks []AfterHook
|
||||
}
|
||||
|
||||
// NewTaskMutationGate creates a gate without a store.
|
||||
|
|
@ -76,10 +114,29 @@ func (g *TaskMutationGate) OnDelete(v MutationValidator) {
|
|||
g.deleteValidators = append(g.deleteValidators, v)
|
||||
}
|
||||
|
||||
// CreateTask validates the task, sets timestamps, and persists it.
|
||||
func (g *TaskMutationGate) CreateTask(t *task.Task) error {
|
||||
// OnAfterCreate registers a hook that runs after a successful CreateTask.
|
||||
func (g *TaskMutationGate) OnAfterCreate(h AfterHook) {
|
||||
g.afterCreateHooks = append(g.afterCreateHooks, h)
|
||||
}
|
||||
|
||||
// OnAfterUpdate registers a hook that runs after a successful UpdateTask.
|
||||
func (g *TaskMutationGate) OnAfterUpdate(h AfterHook) {
|
||||
g.afterUpdateHooks = append(g.afterUpdateHooks, h)
|
||||
}
|
||||
|
||||
// OnAfterDelete registers a hook that runs after a successful DeleteTask.
|
||||
func (g *TaskMutationGate) OnAfterDelete(h AfterHook) {
|
||||
g.afterDeleteHooks = append(g.afterDeleteHooks, h)
|
||||
}
|
||||
|
||||
// CreateTask validates the task, sets timestamps, persists it, and runs after-hooks.
|
||||
func (g *TaskMutationGate) CreateTask(ctx context.Context, t *task.Task) error {
|
||||
if err := checkTriggerDepth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ensureStore()
|
||||
if err := g.runValidators(g.createValidators, t); err != nil {
|
||||
allTasks := append(g.store.GetAllTasks(), t)
|
||||
if err := g.runValidators(g.createValidators, nil, t, allTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
|
|
@ -87,27 +144,55 @@ func (g *TaskMutationGate) CreateTask(t *task.Task) error {
|
|||
t.CreatedAt = now
|
||||
}
|
||||
t.UpdatedAt = now
|
||||
return g.store.CreateTask(t)
|
||||
if err := g.store.CreateTask(t); err != nil {
|
||||
return err
|
||||
}
|
||||
g.runAfterHooks(ctx, g.afterCreateHooks, nil, t.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTask validates the task, sets UpdatedAt, and persists changes.
|
||||
func (g *TaskMutationGate) UpdateTask(t *task.Task) error {
|
||||
// UpdateTask validates the task, sets UpdatedAt, persists changes, and runs after-hooks.
|
||||
func (g *TaskMutationGate) UpdateTask(ctx context.Context, t *task.Task) error {
|
||||
if err := checkTriggerDepth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ensureStore()
|
||||
if err := g.runValidators(g.updateValidators, t); err != nil {
|
||||
raw := g.store.GetTask(t.ID)
|
||||
if raw == nil {
|
||||
return fmt.Errorf("task not found: %s", t.ID)
|
||||
}
|
||||
old := raw.Clone()
|
||||
allTasks := g.candidateAllTasks(t)
|
||||
if err := g.runValidators(g.updateValidators, old, t, allTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
t.UpdatedAt = time.Now()
|
||||
return g.store.UpdateTask(t)
|
||||
if err := g.store.UpdateTask(t); err != nil {
|
||||
return err
|
||||
}
|
||||
g.runAfterHooks(ctx, g.afterUpdateHooks, old, t.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTask validates and removes a task.
|
||||
// DeleteTask validates, removes a task, and runs after-hooks.
|
||||
// Receives the full task so delete validators can inspect it.
|
||||
func (g *TaskMutationGate) DeleteTask(t *task.Task) error {
|
||||
func (g *TaskMutationGate) DeleteTask(ctx context.Context, t *task.Task) error {
|
||||
if err := checkTriggerDepth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ensureStore()
|
||||
if err := g.runValidators(g.deleteValidators, t); err != nil {
|
||||
raw := g.store.GetTask(t.ID)
|
||||
if raw == nil {
|
||||
// task already gone — skip
|
||||
return nil
|
||||
}
|
||||
old := raw.Clone()
|
||||
allTasks := g.store.GetAllTasks()
|
||||
if err := g.runValidators(g.deleteValidators, old, nil, allTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
g.store.DeleteTask(t.ID)
|
||||
g.runAfterHooks(ctx, g.afterDeleteHooks, old, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -121,10 +206,27 @@ func (g *TaskMutationGate) AddComment(taskID string, comment task.Comment) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) runValidators(validators []MutationValidator, t *task.Task) error {
|
||||
// candidateAllTasks returns a snapshot of all tasks with the proposed update
|
||||
// applied. This lets before-update validators evaluate aggregate predicates
|
||||
// (e.g. WIP limits via count(select ...)) against the candidate world state
|
||||
// rather than the stale pre-mutation snapshot.
|
||||
func (g *TaskMutationGate) candidateAllTasks(proposed *task.Task) []*task.Task {
|
||||
stored := g.store.GetAllTasks()
|
||||
result := make([]*task.Task, len(stored))
|
||||
for i, t := range stored {
|
||||
if t.ID == proposed.ID {
|
||||
result[i] = proposed
|
||||
} else {
|
||||
result[i] = t
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) runValidators(validators []MutationValidator, old, new *task.Task, allTasks []*task.Task) error {
|
||||
var rejections []Rejection
|
||||
for _, v := range validators {
|
||||
if r := v(t); r != nil {
|
||||
if r := v(old, new, allTasks); r != nil {
|
||||
rejections = append(rejections, *r)
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +236,22 @@ func (g *TaskMutationGate) runValidators(validators []MutationValidator, t *task
|
|||
return nil
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) runAfterHooks(ctx context.Context, hooks []AfterHook, old, new *task.Task) {
|
||||
for _, h := range hooks {
|
||||
if err := h(ctx, old, new); err != nil {
|
||||
slog.Error("after-hook failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkTriggerDepth returns an error if the trigger cascade depth exceeds the limit.
|
||||
func checkTriggerDepth(ctx context.Context) error {
|
||||
if triggerDepth(ctx) > maxTriggerDepth {
|
||||
return fmt.Errorf("trigger cascade depth exceeded (max %d)", maxTriggerDepth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) ensureStore() {
|
||||
if g.store == nil {
|
||||
panic("TaskMutationGate: store not set — call SetStore before using mutations or ReadStore")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -38,7 +40,7 @@ func TestCreateTask_Success(t *testing.T) {
|
|||
Priority: 3,
|
||||
}
|
||||
|
||||
if err := gate.CreateTask(tk); err != nil {
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +80,7 @@ func TestCreateTask_DoesNotOverwriteCreatedAt(t *testing.T) {
|
|||
CreatedAt: past,
|
||||
}
|
||||
|
||||
if err := gate.CreateTask(tk); err != nil {
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +104,7 @@ func (s *spyStore) CreateTask(tk *task.Task) error {
|
|||
|
||||
func TestCreateTask_RejectedByValidator(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
gate.OnCreate(func(tk *task.Task) *Rejection {
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "blocked"}
|
||||
})
|
||||
|
||||
|
|
@ -114,7 +116,7 @@ func TestCreateTask_RejectedByValidator(t *testing.T) {
|
|||
Priority: 3,
|
||||
}
|
||||
|
||||
err := gate.CreateTask(tk)
|
||||
err := gate.CreateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection error")
|
||||
}
|
||||
|
|
@ -144,7 +146,7 @@ func TestUpdateTask_Success(t *testing.T) {
|
|||
_ = s.CreateTask(tk)
|
||||
|
||||
tk.Title = "updated"
|
||||
if err := gate.UpdateTask(tk); err != nil {
|
||||
if err := gate.UpdateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -159,8 +161,8 @@ func TestUpdateTask_Success(t *testing.T) {
|
|||
|
||||
func TestUpdateTask_RejectedByValidator(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
gate.OnUpdate(func(tk *task.Task) *Rejection {
|
||||
if tk.Title == "bad" {
|
||||
gate.OnUpdate(func(_, new *task.Task, _ []*task.Task) *Rejection {
|
||||
if new.Title == "bad" {
|
||||
return &Rejection{Reason: "title cannot be 'bad'"}
|
||||
}
|
||||
return nil
|
||||
|
|
@ -178,7 +180,7 @@ func TestUpdateTask_RejectedByValidator(t *testing.T) {
|
|||
// clone to avoid mutating the store's pointer
|
||||
modified := original.Clone()
|
||||
modified.Title = "bad"
|
||||
err := gate.UpdateTask(modified)
|
||||
err := gate.UpdateTask(context.Background(), modified)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
|
|
@ -201,7 +203,7 @@ func TestDeleteTask_Success(t *testing.T) {
|
|||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
if err := gate.DeleteTask(tk); err != nil {
|
||||
if err := gate.DeleteTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +214,7 @@ func TestDeleteTask_Success(t *testing.T) {
|
|||
|
||||
func TestDeleteTask_RejectedByValidator(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
gate.OnDelete(func(tk *task.Task) *Rejection {
|
||||
gate.OnDelete(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "cannot delete"}
|
||||
})
|
||||
|
||||
|
|
@ -225,7 +227,7 @@ func TestDeleteTask_RejectedByValidator(t *testing.T) {
|
|||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
err := gate.DeleteTask(tk)
|
||||
err := gate.DeleteTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
|
|
@ -275,10 +277,10 @@ func TestAddComment_TaskNotFound(t *testing.T) {
|
|||
func TestMultipleRejections(t *testing.T) {
|
||||
gate, _ := newGateWithStore()
|
||||
|
||||
gate.OnCreate(func(tk *task.Task) *Rejection {
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "reason one"}
|
||||
})
|
||||
gate.OnCreate(func(tk *task.Task) *Rejection {
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "reason two"}
|
||||
})
|
||||
|
||||
|
|
@ -290,7 +292,7 @@ func TestMultipleRejections(t *testing.T) {
|
|||
Priority: 3,
|
||||
}
|
||||
|
||||
err := gate.CreateTask(tk)
|
||||
err := gate.CreateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection error")
|
||||
}
|
||||
|
|
@ -319,19 +321,24 @@ func TestSingleRejection_ErrorFormat(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFieldValidators_RejectInvalidTask(t *testing.T) {
|
||||
gate, _ := newGateWithStore()
|
||||
gate, s := newGateWithStore()
|
||||
RegisterFieldValidators(gate)
|
||||
|
||||
// task with invalid priority — should be rejected
|
||||
tk := &task.Task{
|
||||
// create a valid task first so UpdateTask can find it in the store
|
||||
valid := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 99,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(valid)
|
||||
|
||||
err := gate.UpdateTask(tk)
|
||||
// now try to update with invalid priority — should be rejected
|
||||
tk := valid.Clone()
|
||||
tk.Priority = 99
|
||||
|
||||
err := gate.UpdateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for invalid priority")
|
||||
}
|
||||
|
|
@ -365,7 +372,7 @@ func TestFieldValidators_AcceptValidTask(t *testing.T) {
|
|||
Priority: 3,
|
||||
}
|
||||
|
||||
if err := gate.CreateTask(tk); err != nil {
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -398,14 +405,14 @@ func TestEnsureStore_Panics(t *testing.T) {
|
|||
}
|
||||
}()
|
||||
|
||||
_ = gate.CreateTask(&task.Task{})
|
||||
_ = gate.CreateTask(context.Background(), &task.Task{})
|
||||
}
|
||||
|
||||
func TestCreateValidatorDoesNotAffectUpdate(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
// register a validator only on create
|
||||
gate.OnCreate(func(tk *task.Task) *Rejection {
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "create blocked"}
|
||||
})
|
||||
|
||||
|
|
@ -420,7 +427,7 @@ func TestCreateValidatorDoesNotAffectUpdate(t *testing.T) {
|
|||
_ = s.CreateTask(tk)
|
||||
|
||||
tk.Title = "updated"
|
||||
if err := gate.UpdateTask(tk); err != nil {
|
||||
if err := gate.UpdateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("update should not be affected by create validator: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -438,13 +445,203 @@ func TestBuildGate(t *testing.T) {
|
|||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
if err := gate.CreateTask(tk); err == nil {
|
||||
if err := gate.CreateTask(context.Background(), tk); err == nil {
|
||||
t.Fatal("expected rejection for empty title")
|
||||
}
|
||||
|
||||
// a valid task should succeed
|
||||
tk.Title = "valid"
|
||||
if err := gate.CreateTask(tk); err != nil {
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_CalledWithCorrectOldNew(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "original",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
var hookOld, hookNew *task.Task
|
||||
gate.OnAfterUpdate(func(_ context.Context, old, new *task.Task) error {
|
||||
hookOld = old
|
||||
hookNew = new
|
||||
return nil
|
||||
})
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "changed"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if hookOld == nil || hookOld.Title != "original" {
|
||||
t.Errorf("after-hook old should have original title, got %v", hookOld)
|
||||
}
|
||||
if hookNew == nil || hookNew.Title != "changed" {
|
||||
t.Errorf("after-hook new should have changed title, got %v", hookNew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_ErrorSwallowed(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
gate.OnAfterUpdate(func(_ context.Context, _, _ *task.Task) error {
|
||||
return fmt.Errorf("hook error")
|
||||
})
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "new title"
|
||||
// error from after-hook should not propagate
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("after-hook error should not propagate: %v", err)
|
||||
}
|
||||
|
||||
// task should still be persisted
|
||||
stored := s.GetTask("TIKI-ABC123")
|
||||
if stored.Title != "new title" {
|
||||
t.Errorf("task should have been updated despite hook error, got %q", stored.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_CreateAndDelete(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
var createCalled, deleteCalled bool
|
||||
gate.OnAfterCreate(func(_ context.Context, old, new *task.Task) error {
|
||||
createCalled = true
|
||||
if old != nil {
|
||||
t.Error("create after-hook: old should be nil")
|
||||
}
|
||||
if new == nil || new.Title != "new task" {
|
||||
t.Error("create after-hook: new should have title")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
gate.OnAfterDelete(func(_ context.Context, old, new *task.Task) error {
|
||||
deleteCalled = true
|
||||
if old == nil || old.Title != "new task" {
|
||||
t.Error("delete after-hook: old should have title")
|
||||
}
|
||||
if new != nil {
|
||||
t.Error("delete after-hook: new should be nil")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "new task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("create error: %v", err)
|
||||
}
|
||||
if !createCalled {
|
||||
t.Error("create after-hook not called")
|
||||
}
|
||||
|
||||
if err := gate.DeleteTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("delete error: %v", err)
|
||||
}
|
||||
if !deleteCalled {
|
||||
t.Error("delete after-hook not called")
|
||||
}
|
||||
|
||||
if s.GetTask("TIKI-ABC123") != nil {
|
||||
t.Error("task should have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_Ordering(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
// hook A mutates a second task through the gate
|
||||
second := &task.Task{
|
||||
ID: "TIKI-BBB222",
|
||||
Title: "second",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(second)
|
||||
|
||||
gate.OnAfterUpdate(func(ctx context.Context, _, new *task.Task) error {
|
||||
// only fire for the original trigger, not for the cascaded mutation
|
||||
if new.ID != "TIKI-ABC123" {
|
||||
return nil
|
||||
}
|
||||
sec := s.GetTask("TIKI-BBB222")
|
||||
if sec == nil {
|
||||
return nil
|
||||
}
|
||||
upd := sec.Clone()
|
||||
upd.Title = "modified by hook A"
|
||||
return gate.UpdateTask(ctx, upd)
|
||||
})
|
||||
|
||||
// hook B checks that it sees hook A's mutation
|
||||
var hookBSawMutation bool
|
||||
gate.OnAfterUpdate(func(_ context.Context, _, _ *task.Task) error {
|
||||
sec := s.GetTask("TIKI-BBB222")
|
||||
if sec != nil && sec.Title == "modified by hook A" {
|
||||
hookBSawMutation = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "trigger"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !hookBSawMutation {
|
||||
t.Error("hook B should see hook A's mutation in the store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerDepth_NilContext(t *testing.T) {
|
||||
// triggerDepth must not panic on nil context
|
||||
depth := triggerDepth(nil) //nolint:staticcheck // SA1012: intentionally testing nil-context safety
|
||||
if depth != 0 {
|
||||
t.Fatalf("expected 0, got %d", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTriggerDepth_NilContext(t *testing.T) {
|
||||
// withTriggerDepth must not panic on nil context
|
||||
ctx := withTriggerDepth(nil, 3) //nolint:staticcheck // SA1012: intentionally testing nil-context safety
|
||||
if ctx == nil {
|
||||
t.Fatal("expected non-nil context")
|
||||
}
|
||||
if got := triggerDepth(ctx); got != 3 {
|
||||
t.Fatalf("expected depth 3, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
245
service/trigger_engine.go
Normal file
245
service/trigger_engine.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// maxTriggerDepth is the maximum cascade depth for triggers.
|
||||
// Root mutation is depth 0; up to 8 cascades are allowed.
|
||||
const maxTriggerDepth = 8
|
||||
|
||||
// runCommandTimeout is the timeout for run() commands executed by triggers.
|
||||
const runCommandTimeout = 30 * time.Second
|
||||
|
||||
// triggerEntry holds a parsed trigger and its description for logging.
|
||||
type triggerEntry struct {
|
||||
description string
|
||||
trigger *ruki.Trigger
|
||||
}
|
||||
|
||||
// TriggerEngine bridges parsed triggers with the mutation gate.
|
||||
// Before-triggers become MutationValidators, after-triggers become AfterHooks.
|
||||
type TriggerEngine struct {
|
||||
beforeCreate []triggerEntry
|
||||
beforeUpdate []triggerEntry
|
||||
beforeDelete []triggerEntry
|
||||
afterCreate []triggerEntry
|
||||
afterUpdate []triggerEntry
|
||||
afterDelete []triggerEntry
|
||||
executor *ruki.TriggerExecutor
|
||||
gate *TaskMutationGate
|
||||
}
|
||||
|
||||
// NewTriggerEngine creates a TriggerEngine from parsed triggers.
|
||||
func NewTriggerEngine(triggers []triggerEntry, executor *ruki.TriggerExecutor) *TriggerEngine {
|
||||
te := &TriggerEngine{executor: executor}
|
||||
for _, entry := range triggers {
|
||||
te.addTrigger(entry)
|
||||
}
|
||||
return te
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) addTrigger(entry triggerEntry) {
|
||||
trig := entry.trigger
|
||||
switch {
|
||||
case trig.Timing == "before" && trig.Event == "create":
|
||||
te.beforeCreate = append(te.beforeCreate, entry)
|
||||
case trig.Timing == "before" && trig.Event == "update":
|
||||
te.beforeUpdate = append(te.beforeUpdate, entry)
|
||||
case trig.Timing == "before" && trig.Event == "delete":
|
||||
te.beforeDelete = append(te.beforeDelete, entry)
|
||||
case trig.Timing == "after" && trig.Event == "create":
|
||||
te.afterCreate = append(te.afterCreate, entry)
|
||||
case trig.Timing == "after" && trig.Event == "update":
|
||||
te.afterUpdate = append(te.afterUpdate, entry)
|
||||
case trig.Timing == "after" && trig.Event == "delete":
|
||||
te.afterDelete = append(te.afterDelete, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWithGate wires the triggers into the gate as validators and hooks.
|
||||
func (te *TriggerEngine) RegisterWithGate(gate *TaskMutationGate) {
|
||||
te.gate = gate
|
||||
|
||||
// before-triggers become validators
|
||||
for _, entry := range te.beforeCreate {
|
||||
gate.OnCreate(te.makeBeforeValidator(entry))
|
||||
}
|
||||
for _, entry := range te.beforeUpdate {
|
||||
gate.OnUpdate(te.makeBeforeValidator(entry))
|
||||
}
|
||||
for _, entry := range te.beforeDelete {
|
||||
gate.OnDelete(te.makeBeforeValidator(entry))
|
||||
}
|
||||
|
||||
// after-triggers become hooks
|
||||
for _, entry := range te.afterCreate {
|
||||
gate.OnAfterCreate(te.makeAfterHook(entry))
|
||||
}
|
||||
for _, entry := range te.afterUpdate {
|
||||
gate.OnAfterUpdate(te.makeAfterHook(entry))
|
||||
}
|
||||
for _, entry := range te.afterDelete {
|
||||
gate.OnAfterDelete(te.makeAfterHook(entry))
|
||||
}
|
||||
}
|
||||
|
||||
// makeBeforeValidator creates a MutationValidator from a before-trigger.
|
||||
// Fail-closed: guard evaluation errors produce a rejection.
|
||||
func (te *TriggerEngine) makeBeforeValidator(entry triggerEntry) MutationValidator {
|
||||
return func(old, new *task.Task, allTasks []*task.Task) *Rejection {
|
||||
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
|
||||
match, err := te.executor.EvalGuard(entry.trigger, tc)
|
||||
if err != nil {
|
||||
return &Rejection{
|
||||
Reason: fmt.Sprintf("trigger %q guard evaluation failed: %v", entry.description, err),
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return &Rejection{Reason: *entry.trigger.Deny}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeAfterHook creates an AfterHook from an after-trigger.
|
||||
// Guard evaluation errors are logged and the trigger is skipped.
|
||||
func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
|
||||
return func(ctx context.Context, old, new *task.Task) error {
|
||||
depth := triggerDepth(ctx)
|
||||
if depth >= maxTriggerDepth {
|
||||
slog.Warn("trigger cascade depth exceeded, skipping",
|
||||
"trigger", entry.description, "depth", depth)
|
||||
return nil
|
||||
}
|
||||
|
||||
allTasks := te.gate.ReadStore().GetAllTasks()
|
||||
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
|
||||
|
||||
match, err := te.executor.EvalGuard(entry.trigger, tc)
|
||||
if err != nil {
|
||||
slog.Error("after-trigger guard evaluation failed",
|
||||
"trigger", entry.description, "error", err)
|
||||
return nil
|
||||
}
|
||||
if !match {
|
||||
return nil
|
||||
}
|
||||
|
||||
childCtx := withTriggerDepth(ctx, depth+1)
|
||||
|
||||
if entry.trigger.Run != nil {
|
||||
return te.execRun(childCtx, entry, tc)
|
||||
}
|
||||
return te.execAction(childCtx, entry, tc)
|
||||
}
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) execAction(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
|
||||
result, err := te.executor.ExecAction(entry.trigger, tc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger %q action execution failed: %w", entry.description, err)
|
||||
}
|
||||
return te.persistResult(ctx, result)
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) persistResult(ctx context.Context, result *ruki.Result) error {
|
||||
var errs []error
|
||||
switch {
|
||||
case result.Update != nil:
|
||||
for _, t := range result.Update.Updated {
|
||||
if err := te.gate.UpdateTask(ctx, t); err != nil {
|
||||
errs = append(errs, fmt.Errorf("update %s: %w", t.ID, err))
|
||||
}
|
||||
}
|
||||
case result.Create != nil:
|
||||
t := result.Create.Task
|
||||
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template: %w", err)
|
||||
}
|
||||
t.ID = tmpl.ID
|
||||
t.CreatedBy = tmpl.CreatedBy
|
||||
if err := te.gate.CreateTask(ctx, t); err != nil {
|
||||
return fmt.Errorf("trigger create failed: %w", err)
|
||||
}
|
||||
case result.Delete != nil:
|
||||
for _, t := range result.Delete.Deleted {
|
||||
if err := te.gate.DeleteTask(ctx, t); err != nil {
|
||||
errs = append(errs, fmt.Errorf("delete %s: %w", t.ID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) execRun(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
|
||||
cmdStr, err := te.executor.ExecRun(entry.trigger, tc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger %q run evaluation failed: %w", entry.description, err)
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, runCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "sh", "-c", cmdStr) //nolint:gosec // cmdStr is a user-configured trigger action, intentionally dynamic
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("trigger run() command failed",
|
||||
"trigger", entry.description,
|
||||
"command", cmdStr,
|
||||
"output", string(output),
|
||||
"error", err)
|
||||
return nil // logged, chain continues
|
||||
}
|
||||
|
||||
slog.Info("trigger run() command succeeded",
|
||||
"trigger", entry.description,
|
||||
"command", cmdStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAndRegisterTriggers loads trigger definitions from workflow.yaml, parses them,
|
||||
// and registers them with the gate. Returns the number of triggers loaded.
|
||||
// Fails fast on parse errors — a bad trigger blocks startup.
|
||||
func LoadAndRegisterTriggers(gate *TaskMutationGate, schema ruki.Schema, userFunc func() string) (int, error) {
|
||||
defs, err := config.LoadTriggerDefs()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("loading trigger definitions: %w", err)
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parser := ruki.NewParser(schema)
|
||||
entries := make([]triggerEntry, 0, len(defs))
|
||||
for i, def := range defs {
|
||||
trig, err := parser.ParseTrigger(def.Ruki)
|
||||
if err != nil {
|
||||
desc := def.Description
|
||||
if desc == "" {
|
||||
desc = fmt.Sprintf("#%d", i+1)
|
||||
}
|
||||
return 0, fmt.Errorf("trigger %q: %w", desc, err)
|
||||
}
|
||||
entries = append(entries, triggerEntry{
|
||||
description: def.Description,
|
||||
trigger: trig,
|
||||
})
|
||||
}
|
||||
|
||||
executor := ruki.NewTriggerExecutor(schema, userFunc)
|
||||
engine := NewTriggerEngine(entries, executor)
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
return len(entries), nil
|
||||
}
|
||||
493
service/trigger_engine_test.go
Normal file
493
service/trigger_engine_test.go
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// testTriggerSchema implements ruki.Schema for trigger engine tests.
|
||||
type testTriggerSchema struct{}
|
||||
|
||||
func (testTriggerSchema) Field(name string) (ruki.FieldSpec, bool) {
|
||||
fields := map[string]ruki.FieldSpec{
|
||||
"id": {Name: "id", Type: ruki.ValueID},
|
||||
"title": {Name: "title", Type: ruki.ValueString},
|
||||
"description": {Name: "description", Type: ruki.ValueString},
|
||||
"status": {Name: "status", Type: ruki.ValueStatus},
|
||||
"type": {Name: "type", Type: ruki.ValueTaskType},
|
||||
"tags": {Name: "tags", Type: ruki.ValueListString},
|
||||
"dependsOn": {Name: "dependsOn", Type: ruki.ValueListRef},
|
||||
"due": {Name: "due", Type: ruki.ValueDate},
|
||||
"recurrence": {Name: "recurrence", Type: ruki.ValueRecurrence},
|
||||
"assignee": {Name: "assignee", Type: ruki.ValueString},
|
||||
"priority": {Name: "priority", Type: ruki.ValueInt},
|
||||
"points": {Name: "points", Type: ruki.ValueInt},
|
||||
"createdBy": {Name: "createdBy", Type: ruki.ValueString},
|
||||
"createdAt": {Name: "createdAt", Type: ruki.ValueTimestamp},
|
||||
"updatedAt": {Name: "updatedAt", Type: ruki.ValueTimestamp},
|
||||
}
|
||||
f, ok := fields[name]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func (testTriggerSchema) NormalizeStatus(raw string) (string, bool) {
|
||||
valid := map[string]string{
|
||||
"backlog": "backlog",
|
||||
"ready": "ready",
|
||||
"in progress": "in_progress",
|
||||
"in_progress": "in_progress",
|
||||
"done": "done",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
canonical, ok := valid[raw]
|
||||
return canonical, ok
|
||||
}
|
||||
|
||||
func (testTriggerSchema) NormalizeType(raw string) (string, bool) {
|
||||
valid := map[string]string{
|
||||
"story": "story",
|
||||
"bug": "bug",
|
||||
"spike": "spike",
|
||||
"epic": "epic",
|
||||
}
|
||||
canonical, ok := valid[raw]
|
||||
return canonical, ok
|
||||
}
|
||||
|
||||
func parseTriggerEntry(t *testing.T, desc, input string) triggerEntry {
|
||||
t.Helper()
|
||||
p := ruki.NewParser(testTriggerSchema{})
|
||||
trig, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse trigger %q: %v", desc, err)
|
||||
}
|
||||
return triggerEntry{description: desc, trigger: trig}
|
||||
}
|
||||
|
||||
func newGateWithStoreAndTasks(tasks ...*task.Task) (*TaskMutationGate, store.Store) {
|
||||
gate := NewTaskMutationGate()
|
||||
RegisterFieldValidators(gate)
|
||||
s := store.NewInMemoryStore()
|
||||
gate.SetStore(s)
|
||||
for _, tk := range tasks {
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
panic("setup: " + err.Error())
|
||||
}
|
||||
}
|
||||
return gate, s
|
||||
}
|
||||
|
||||
// --- before-trigger tests ---
|
||||
|
||||
func TestTriggerEngine_BeforeCreateDenyAggregate(t *testing.T) {
|
||||
// task cap: deny when 3+ tasks for same assignee.
|
||||
// Regression: allTasks must include the proposed new task,
|
||||
// otherwise the count undercounts by one.
|
||||
entry := parseTriggerEntry(t, "task cap",
|
||||
`before create where count(select where assignee = new.assignee) >= 3 deny "task cap reached"`)
|
||||
|
||||
existing1 := &task.Task{ID: "TIKI-CAP001", Title: "e1", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
existing2 := &task.Task{ID: "TIKI-CAP002", Title: "e2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(existing1, existing2)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// create a 3rd task for alice — count(alice tasks) = 3, should be denied
|
||||
newTask := &task.Task{ID: "TIKI-CAP003", Title: "e3", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
err := gate.CreateTask(context.Background(), newTask)
|
||||
if err == nil {
|
||||
t.Fatal("expected task cap denial, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "task cap reached") {
|
||||
t.Fatalf("expected task cap message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_BeforeCreateAllowUnderAggregate(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "task cap",
|
||||
`before create where count(select where assignee = new.assignee) >= 3 deny "task cap reached"`)
|
||||
|
||||
existing := &task.Task{ID: "TIKI-CAP001", Title: "e1", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(existing)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// create a 2nd task for alice — count(alice tasks) = 2, should be allowed
|
||||
newTask := &task.Task{ID: "TIKI-CAP002", Title: "e2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
if err := gate.CreateTask(context.Background(), newTask); err != nil {
|
||||
t.Fatalf("unexpected denial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_BeforeDeny(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "block completion with open deps",
|
||||
`before update where old.status = "in_progress" and new.status = "done" deny "cannot skip review"`)
|
||||
|
||||
dep := &task.Task{ID: "TIKI-DEP001", Title: "dep", Status: "in_progress", Type: "story", Priority: 3}
|
||||
main := &task.Task{ID: "TIKI-MAIN01", Title: "main", Status: "in_progress", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(dep, main)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// try to move main to done — should be denied
|
||||
updated := main.Clone()
|
||||
updated.Status = "done"
|
||||
err := gate.UpdateTask(context.Background(), updated)
|
||||
if err == nil {
|
||||
t.Fatal("expected denial, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot skip review") {
|
||||
t.Fatalf("expected denial message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_BeforeDenyNoMatch(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "block completion",
|
||||
`before update where old.status = "in_progress" and new.status = "done" deny "no"`)
|
||||
|
||||
tk := &task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(tk)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// move ready → in_progress — should NOT be denied
|
||||
updated := tk.Clone()
|
||||
updated.Status = "in_progress"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected denial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_BeforeDenyWIPLimit(t *testing.T) {
|
||||
// WIP limit: deny when 3+ in-progress tasks for same assignee.
|
||||
// Regression: allTasks must contain proposed values for the task being updated,
|
||||
// otherwise the count sees the old status and undercounts.
|
||||
entry := parseTriggerEntry(t, "WIP limit",
|
||||
`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached"`)
|
||||
|
||||
// two tasks already in_progress for alice, plus the one about to transition
|
||||
existing1 := &task.Task{ID: "TIKI-WIP001", Title: "a1", Status: "in_progress", Assignee: "alice", Type: "story", Priority: 3}
|
||||
existing2 := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "in_progress", Assignee: "alice", Type: "story", Priority: 3}
|
||||
target := &task.Task{ID: "TIKI-WIP003", Title: "a3", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(existing1, existing2, target)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// move target ready → in_progress — would be 3 in-progress for alice, should be denied
|
||||
updated := target.Clone()
|
||||
updated.Status = "in_progress"
|
||||
err := gate.UpdateTask(context.Background(), updated)
|
||||
if err == nil {
|
||||
t.Fatal("expected WIP limit denial, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "WIP limit reached") {
|
||||
t.Fatalf("expected WIP limit message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_BeforeAllowUnderWIPLimit(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "WIP limit",
|
||||
`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached"`)
|
||||
|
||||
// only one task already in_progress for alice
|
||||
existing := &task.Task{ID: "TIKI-WIP001", Title: "a1", Status: "in_progress", Assignee: "alice", Type: "story", Priority: 3}
|
||||
target := &task.Task{ID: "TIKI-WIP002", Title: "a2", Status: "ready", Assignee: "alice", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(existing, target)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// move target ready → in_progress — only 2 in-progress, should be allowed
|
||||
updated := target.Clone()
|
||||
updated.Status = "in_progress"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected denial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- after-trigger tests ---
|
||||
|
||||
func TestTriggerEngine_AfterUpdateCascade(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "auto-assign urgent",
|
||||
`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="autobot"`)
|
||||
|
||||
gate, s := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// create an urgent task without assignee — trigger should auto-assign
|
||||
tk := &task.Task{ID: "TIKI-URGENT", Title: "urgent bug", Status: "ready", Type: "bug", Priority: 1}
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
persisted := s.GetTask("TIKI-URGENT")
|
||||
if persisted == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if persisted.Assignee != "autobot" {
|
||||
t.Fatalf("expected assignee=autobot, got %q", persisted.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_AfterTriggerNoMatchSkipped(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "auto-assign urgent",
|
||||
`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="autobot"`)
|
||||
|
||||
gate, s := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// create a low-priority task — trigger should NOT fire
|
||||
tk := &task.Task{ID: "TIKI-LOWPRI", Title: "low pri", Status: "ready", Type: "story", Priority: 5}
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
persisted := s.GetTask("TIKI-LOWPRI")
|
||||
if persisted.Assignee != "" {
|
||||
t.Fatalf("expected empty assignee, got %q", persisted.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_AfterDeleteCleanupDeps(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "cleanup deps on delete",
|
||||
`after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`)
|
||||
|
||||
dep := &task.Task{ID: "TIKI-DEP001", Title: "dep", Status: "done", Type: "story", Priority: 3}
|
||||
downstream := &task.Task{
|
||||
ID: "TIKI-DOWN01", Title: "downstream", Status: "ready", Type: "story", Priority: 3,
|
||||
DependsOn: []string{"TIKI-DEP001", "TIKI-OTHER1"},
|
||||
}
|
||||
other := &task.Task{ID: "TIKI-OTHER1", Title: "other", Status: "done", Type: "story", Priority: 3}
|
||||
gate, s := newGateWithStoreAndTasks(dep, downstream, other)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// delete dep — should remove it from downstream's dependsOn
|
||||
if err := gate.DeleteTask(context.Background(), dep); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
|
||||
persisted := s.GetTask("TIKI-DOWN01")
|
||||
if persisted == nil {
|
||||
t.Fatal("downstream task missing")
|
||||
}
|
||||
if len(persisted.DependsOn) != 1 || persisted.DependsOn[0] != "TIKI-OTHER1" {
|
||||
t.Fatalf("expected dependsOn=[TIKI-OTHER1], got %v", persisted.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_AfterCascadePartialFailureSurfaced(t *testing.T) {
|
||||
// trigger: after create, update all tasks with same assignee to priority=1.
|
||||
// One of the target tasks has an invalid state (priority=0 after set, which
|
||||
// passes here), but we deliberately set up a validator that rejects priority=0.
|
||||
// The trigger should succeed for some tasks and fail for the blocked one.
|
||||
// Key assertion: the successful updates persist, the failed one doesn't.
|
||||
entry := parseTriggerEntry(t, "cascade to peers",
|
||||
`after create where new.priority = 1 update where assignee = new.assignee and id != new.id set priority=1`)
|
||||
|
||||
peer1 := &task.Task{ID: "TIKI-PEER01", Title: "peer1", Status: "ready", Assignee: "alice", Type: "story", Priority: 5}
|
||||
peer2 := &task.Task{ID: "TIKI-PEER02", Title: "peer2", Status: "ready", Assignee: "alice", Type: "story", Priority: 5}
|
||||
gate, s := newGateWithStoreAndTasks(peer1, peer2)
|
||||
|
||||
// register a validator that blocks updates to TIKI-PEER02 specifically
|
||||
gate.OnUpdate(func(old, new *task.Task, allTasks []*task.Task) *Rejection {
|
||||
if new.ID == "TIKI-PEER02" && new.Priority == 1 {
|
||||
return &Rejection{Reason: "peer2 blocked"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// create a task that fires the trigger
|
||||
trigger := &task.Task{ID: "TIKI-TRIG01", Title: "trigger", Status: "ready", Assignee: "alice", Type: "story", Priority: 1}
|
||||
if err := gate.CreateTask(context.Background(), trigger); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
// peer1 should have been updated (priority=1)
|
||||
p1 := s.GetTask("TIKI-PEER01")
|
||||
if p1.Priority != 1 {
|
||||
t.Errorf("peer1 priority = %d, want 1 (cascade should succeed)", p1.Priority)
|
||||
}
|
||||
|
||||
// peer2 should NOT have been updated (blocked by validator)
|
||||
p2 := s.GetTask("TIKI-PEER02")
|
||||
if p2.Priority != 5 {
|
||||
t.Errorf("peer2 priority = %d, want 5 (cascade should have been blocked)", p2.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
// --- recursion limit ---
|
||||
|
||||
func TestTriggerEngine_RecursionLimit(t *testing.T) {
|
||||
// trigger that cascades indefinitely: every update triggers another update
|
||||
entry := parseTriggerEntry(t, "infinite cascade",
|
||||
`after update where new.status = "in_progress" update where id = old.id set priority=new.priority`)
|
||||
|
||||
tk := &task.Task{ID: "TIKI-LOOP01", Title: "loop", Status: "ready", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(tk)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// update to in_progress — should cascade but not infinite loop
|
||||
updated := tk.Clone()
|
||||
updated.Status = "in_progress"
|
||||
err := gate.UpdateTask(context.Background(), updated)
|
||||
// should not error — recursion limit is handled gracefully by skipping at depth
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_DepthExceededAtGateLevel(t *testing.T) {
|
||||
gate, _ := newGateWithStoreAndTasks(
|
||||
&task.Task{ID: "TIKI-000001", Title: "test", Status: "ready", Type: "story", Priority: 3},
|
||||
)
|
||||
|
||||
// simulate a context already at max+1 depth
|
||||
ctx := withTriggerDepth(context.Background(), maxTriggerDepth+1)
|
||||
updated := &task.Task{ID: "TIKI-000001", Title: "test", Status: "in_progress", Type: "story", Priority: 3}
|
||||
err := gate.UpdateTask(ctx, updated)
|
||||
if err == nil {
|
||||
t.Fatal("expected depth exceeded error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cascade depth exceeded") {
|
||||
t.Fatalf("expected cascade depth error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- run() trigger ---
|
||||
|
||||
func TestTriggerEngine_RunCommand(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "echo trigger",
|
||||
`after update where new.status = "done" run("echo " + old.id)`)
|
||||
|
||||
tk := &task.Task{ID: "TIKI-RUN001", Title: "run test", Status: "in_progress", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(tk)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Status = "done"
|
||||
// should not error — command succeeds
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_RunCommandFailure(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "failing command",
|
||||
`after update where new.status = "done" run("exit 1")`)
|
||||
|
||||
tk := &task.Task{ID: "TIKI-FAIL01", Title: "fail test", Status: "in_progress", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(tk)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Status = "done"
|
||||
// run() failure is logged, not propagated — mutation should succeed
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected error (run failure should be swallowed): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_RunCommandTimeout(t *testing.T) {
|
||||
// use a run() trigger whose command outlives the parent context's deadline
|
||||
entry := parseTriggerEntry(t, "slow command",
|
||||
`after update where new.status = "done" run("sleep 30")`)
|
||||
|
||||
tk := &task.Task{ID: "TIKI-SLOW01", Title: "slow", Status: "in_progress", Type: "story", Priority: 3}
|
||||
gate, _ := newGateWithStoreAndTasks(tk)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
// parent context with a very short deadline — the 30s sleep will be killed
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Status = "done"
|
||||
|
||||
start := time.Now()
|
||||
// mutation should succeed (run failures are logged, not propagated)
|
||||
if err := gate.UpdateTask(ctx, updated); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// the command should have been killed quickly, not run for 30 seconds
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("expected timeout to kill the command quickly, but took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_AfterUpdateCreateWithNextDate(t *testing.T) {
|
||||
entry := parseTriggerEntry(t, "recurring follow-up",
|
||||
`after update where new.status = "done" and old.recurrence is not empty create title=old.title status="ready" type=old.type priority=old.priority due=next_date(old.recurrence)`)
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-REC001", Title: "Daily standup", Status: "in_progress",
|
||||
Type: "story", Priority: 3, Recurrence: task.RecurrenceDaily,
|
||||
}
|
||||
gate, s := newGateWithStoreAndTasks(tk)
|
||||
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
before := time.Now()
|
||||
updated := tk.Clone()
|
||||
updated.Status = "done"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
after := time.Now()
|
||||
|
||||
allTasks := s.GetAllTasks()
|
||||
if len(allTasks) < 2 {
|
||||
t.Fatalf("expected at least 2 tasks (original + created), got %d", len(allTasks))
|
||||
}
|
||||
|
||||
// find created task by predicate, not by slice index
|
||||
var created *task.Task
|
||||
for _, at := range allTasks {
|
||||
if at.ID != "TIKI-REC001" {
|
||||
created = at
|
||||
break
|
||||
}
|
||||
}
|
||||
if created == nil {
|
||||
t.Fatal("trigger-created task not found")
|
||||
}
|
||||
if created.Title != "Daily standup" {
|
||||
t.Fatalf("expected title 'Daily standup', got %q", created.Title)
|
||||
}
|
||||
if created.Due.IsZero() {
|
||||
t.Fatal("expected non-zero due date from next_date(old.recurrence)")
|
||||
}
|
||||
expBefore := task.NextOccurrenceFrom(task.RecurrenceDaily, before)
|
||||
expAfter := task.NextOccurrenceFrom(task.RecurrenceDaily, after)
|
||||
if !created.Due.Equal(expBefore) && !created.Due.Equal(expAfter) {
|
||||
t.Fatalf("expected due=%v or %v, got %v", expBefore, expAfter, created.Due)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,12 @@ func RegisterFieldValidators(g *TaskMutationGate) {
|
|||
}
|
||||
|
||||
func wrapFieldValidator(fn func(*task.Task) string) MutationValidator {
|
||||
return func(t *task.Task) *Rejection {
|
||||
return func(old, new *task.Task, allTasks []*task.Task) *Rejection {
|
||||
// field validators only inspect the proposed task
|
||||
t := new
|
||||
if t == nil {
|
||||
t = old // delete case
|
||||
}
|
||||
if msg := fn(t); msg != "" {
|
||||
return &Rejection{Reason: msg}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue