mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
replace plugin expressions with ruki
This commit is contained in:
parent
65215da3b0
commit
00ba1ed8ec
38 changed files with 488 additions and 4703 deletions
|
|
@ -29,18 +29,17 @@ views:
|
|||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready' and type != 'epic'
|
||||
action: status = 'ready'
|
||||
filter: select where status = "ready" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
filter: status = 'inProgress' and type != 'epic'
|
||||
action: status = 'inProgress'
|
||||
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
filter: status = 'review' and type != 'epic'
|
||||
action: status = 'review'
|
||||
filter: select where status = "review" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
filter: status = 'done' and type != 'epic'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
filter: select where status = "done" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="done"
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
foreground: "#5fff87"
|
||||
|
|
@ -49,12 +48,11 @@ views:
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
foreground: "#f4d6a6"
|
||||
|
|
@ -63,8 +61,7 @@ views:
|
|||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: NOW - UpdatedAt < 24hours
|
||||
sort: UpdatedAt DESC
|
||||
filter: select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
foreground: "#e2e8f0"
|
||||
|
|
@ -74,19 +71,18 @@ views:
|
|||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'ready'
|
||||
action: status = 'ready'
|
||||
filter: select where type = "epic" and status = "ready" order by priority, points desc
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority = 1
|
||||
action: status = 'backlog', priority = 1
|
||||
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority > 1
|
||||
action: status = 'backlog', priority = 2
|
||||
sort: Priority, Points DESC
|
||||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package controller
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -34,6 +36,7 @@ func NewDepsController(
|
|||
pluginDef *plugin.TikiPlugin,
|
||||
navController *NavigationController,
|
||||
statusline *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *DepsController {
|
||||
return &DepsController{
|
||||
pluginBase: pluginBase{
|
||||
|
|
@ -44,6 +47,7 @@ func NewDepsController(
|
|||
navController: navController,
|
||||
statusline: statusline,
|
||||
registry: DepsViewActions(),
|
||||
schema: schema,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -164,39 +168,42 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
|
||||
contextTaskID := dc.pluginDef.TaskID
|
||||
|
||||
// determine which tasks to update and how
|
||||
type update struct {
|
||||
taskID string
|
||||
action plugin.LaneAction
|
||||
}
|
||||
var updates []update
|
||||
|
||||
// build a ruki UPDATE query for the dependency change
|
||||
var query string
|
||||
switch {
|
||||
case sourceLane == depsLaneAll && targetLane == depsLaneBlocks:
|
||||
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorAdd, contextTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn+["%s"]`, movedTaskID, contextTaskID)
|
||||
case sourceLane == depsLaneAll && targetLane == depsLaneDepends:
|
||||
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorAdd, movedTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn+["%s"]`, contextTaskID, movedTaskID)
|
||||
case sourceLane == depsLaneBlocks && targetLane == depsLaneAll:
|
||||
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorRemove, contextTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn-["%s"]`, movedTaskID, contextTaskID)
|
||||
case sourceLane == depsLaneDepends && targetLane == depsLaneAll:
|
||||
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorRemove, movedTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn-["%s"]`, contextTaskID, movedTaskID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for _, u := range updates {
|
||||
taskItem := dc.taskStore.GetTask(u.taskID)
|
||||
if taskItem == nil {
|
||||
slog.Error("deps move: task not found", "task_id", u.taskID)
|
||||
return false
|
||||
}
|
||||
updated, err := plugin.ApplyLaneAction(taskItem, u.action, "")
|
||||
if err != nil {
|
||||
slog.Error("deps move: failed to apply action", "task_id", u.taskID, "error", err)
|
||||
return false
|
||||
}
|
||||
parser := ruki.NewParser(dc.schema)
|
||||
stmt, err := parser.ParseAndValidateStatement(query, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
slog.Error("deps move: failed to parse ruki query", "query", query, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
executor := dc.newExecutor()
|
||||
result, err := executor.Execute(stmt, dc.taskStore.GetAllTasks())
|
||||
if err != nil {
|
||||
slog.Error("deps move: failed to execute ruki query", "query", query, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if result.Update == nil || len(result.Update.Updated) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, updated := range result.Update.Updated {
|
||||
if err := dc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("deps move: failed to update task", "task_id", u.taskID, "error", err)
|
||||
slog.Error("deps move: failed to update task", "task_id", updated.ID, "error", err)
|
||||
if dc.statusline != nil {
|
||||
dc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
|
|
@ -208,17 +215,6 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// depsAction builds a LaneAction that adds or removes a single task ID from dependsOn.
|
||||
func depsAction(op plugin.ActionOperator, taskID string) plugin.LaneAction {
|
||||
return plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{{
|
||||
Field: plugin.ActionFieldDependsOn,
|
||||
Operator: op,
|
||||
DependsOn: []string{taskID},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDependsTasks looks up full task objects for the context task's DependsOn IDs.
|
||||
func (dc *DepsController) resolveDependsTasks(contextTask *task.Task, allTasks []*task.Task) []*task.Task {
|
||||
if len(contextTask.DependsOn) == 0 {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"slices"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
|
|
@ -52,7 +53,7 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
|
|||
gate.SetStore(taskStore)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil, rukiRuntime.NewSchema())
|
||||
return dc, taskStore
|
||||
}
|
||||
|
||||
|
|
@ -467,7 +468,7 @@ func TestDepsController_DeleteTask_GateError(t *testing.T) {
|
|||
|
||||
nav := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline, rukiRuntime.NewSchema())
|
||||
|
||||
// select free task in All lane
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
|
|
@ -512,7 +513,7 @@ func TestDepsController_MoveTask_UpdateError(t *testing.T) {
|
|||
|
||||
nav := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline, rukiRuntime.NewSchema())
|
||||
|
||||
// select free task in All lane, move left → Blocks
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
|
|
@ -626,7 +627,7 @@ func newDepsNavEnv(t *testing.T, blockers int, allTasks int, depends int, laneCo
|
|||
gate.SetStore(taskStore)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
|
||||
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil, rukiRuntime.NewSchema())
|
||||
}
|
||||
|
||||
func TestDepsController_NavRightAdjacentNonEmptyPreservesRow(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -51,6 +52,7 @@ type InputRouter struct {
|
|||
taskStore store.Store
|
||||
mutationGate *service.TaskMutationGate
|
||||
statusline *model.StatuslineConfig
|
||||
schema ruki.Schema
|
||||
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ func NewInputRouter(
|
|||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
statusline *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *InputRouter {
|
||||
return &InputRouter{
|
||||
navController: navController,
|
||||
|
|
@ -72,6 +75,7 @@ func NewInputRouter(
|
|||
taskStore: taskStore,
|
||||
mutationGate: mutationGate,
|
||||
statusline: statusline,
|
||||
schema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +248,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
|
|||
pluginConfig.SetViewMode(vm)
|
||||
}
|
||||
|
||||
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline)
|
||||
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline, ir.schema)
|
||||
|
||||
if ir.registerPlugin != nil {
|
||||
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import (
|
|||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -28,6 +28,7 @@ func NewPluginController(
|
|||
pluginDef *plugin.TikiPlugin,
|
||||
navController *NavigationController,
|
||||
statusline *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *PluginController {
|
||||
pc := &PluginController{
|
||||
pluginBase: pluginBase{
|
||||
|
|
@ -38,6 +39,7 @@ func NewPluginController(
|
|||
navController: navController,
|
||||
statusline: statusline,
|
||||
registry: PluginViewActions(),
|
||||
schema: schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -151,32 +153,67 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
executor := pc.newExecutor()
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
|
||||
input := ruki.ExecutionInput{}
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
taskItem := pc.taskStore.GetTask(taskID)
|
||||
if taskItem == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
updated, err := plugin.ApplyLaneAction(taskItem, pa.Action, currentUser)
|
||||
if err != nil {
|
||||
slog.Error("failed to apply plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
if pa.Action.IsUpdate() || pa.Action.IsDelete() {
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
input.SelectedTaskID = taskID
|
||||
}
|
||||
|
||||
if pa.Action.IsCreate() {
|
||||
template, err := pc.taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
|
||||
return false
|
||||
}
|
||||
input.CreateTemplate = template
|
||||
}
|
||||
|
||||
result, err := executor.Execute(pa.Action, allTasks, input)
|
||||
if err != nil {
|
||||
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
pc.ensureSearchResultIncludesTask(updated)
|
||||
ctx := context.Background()
|
||||
switch {
|
||||
case result.Update != nil:
|
||||
for _, updated := range result.Update.Updated {
|
||||
if err := pc.mutationGate.UpdateTask(ctx, updated); err != nil {
|
||||
slog.Error("failed to update task after plugin action", "task_id", updated.ID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
pc.ensureSearchResultIncludesTask(updated)
|
||||
}
|
||||
case result.Create != nil:
|
||||
if err := pc.mutationGate.CreateTask(ctx, result.Create.Task); err != nil {
|
||||
slog.Error("failed to create task from plugin action", "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
case result.Delete != nil:
|
||||
for _, deleted := range result.Delete.Deleted {
|
||||
if err := pc.mutationGate.DeleteTask(ctx, deleted); err != nil {
|
||||
slog.Error("failed to delete task from plugin action", "task_id", deleted.ID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
return true
|
||||
}
|
||||
|
|
@ -197,18 +234,24 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
taskItem := pc.taskStore.GetTask(taskID)
|
||||
if taskItem == nil {
|
||||
actionStmt := pc.pluginDef.Lanes[targetLane].Action
|
||||
if actionStmt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
updated, err := plugin.ApplyLaneAction(taskItem, pc.pluginDef.Lanes[targetLane].Action, currentUser)
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
executor := pc.newExecutor()
|
||||
result, err := executor.Execute(actionStmt, allTasks, ruki.ExecutionInput{SelectedTaskID: taskID})
|
||||
if err != nil {
|
||||
slog.Error("failed to apply lane action", "task_id", taskID, "error", err)
|
||||
slog.Error("failed to execute lane action", "task_id", taskID, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if result.Update == nil || len(result.Update.Updated) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := result.Update.Updated[0]
|
||||
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 {
|
||||
|
|
@ -231,22 +274,24 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
return nil
|
||||
}
|
||||
|
||||
// check if search is active
|
||||
searchResults := pc.pluginConfig.GetSearchResults()
|
||||
|
||||
filterStmt := pc.pluginDef.Lanes[lane].Filter
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
now := time.Now()
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
|
||||
var filtered []*task.Task
|
||||
for _, task := range allTasks {
|
||||
laneFilter := pc.pluginDef.Lanes[lane].Filter
|
||||
if laneFilter == nil || laneFilter.Evaluate(task, now, currentUser) {
|
||||
filtered = append(filtered, task)
|
||||
if filterStmt == nil {
|
||||
filtered = allTasks
|
||||
} else {
|
||||
executor := pc.newExecutor()
|
||||
result, err := executor.Execute(filterStmt, allTasks)
|
||||
if err != nil {
|
||||
slog.Error("failed to execute lane filter", "lane", lane, "error", err)
|
||||
return nil
|
||||
}
|
||||
filtered = result.Select.Tasks
|
||||
}
|
||||
|
||||
if searchResults != nil {
|
||||
// narrow by search results if active
|
||||
if searchResults := pc.pluginConfig.GetSearchResults(); searchResults != nil {
|
||||
searchTaskMap := make(map[string]bool, len(searchResults))
|
||||
for _, result := range searchResults {
|
||||
searchTaskMap[result.Task.ID] = true
|
||||
|
|
@ -254,10 +299,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
filtered = filterTasksBySearch(filtered, searchTaskMap)
|
||||
}
|
||||
|
||||
if len(pc.pluginDef.Sort) > 0 {
|
||||
plugin.SortTasks(filtered, pc.pluginDef.Sort)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -22,6 +23,14 @@ type pluginBase struct {
|
|||
navController *NavigationController
|
||||
statusline *model.StatuslineConfig
|
||||
registry *ActionRegistry
|
||||
schema ruki.Schema
|
||||
}
|
||||
|
||||
// newExecutor creates a ruki executor configured for plugin runtime.
|
||||
func (pb *pluginBase) newExecutor() *ruki.Executor {
|
||||
userName := getCurrentUserName(pb.taskStore)
|
||||
return ruki.NewExecutor(pb.schema, func() string { return userName },
|
||||
ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimePlugin})
|
||||
}
|
||||
|
||||
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
|
||||
|
|
|
|||
|
|
@ -4,14 +4,28 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
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/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// mustParseStmt is a test helper that parses and validates a ruki statement,
|
||||
// failing the test on error.
|
||||
func mustParseStmt(t *testing.T, input string) *ruki.ValidatedStatement {
|
||||
t.Helper()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
stmt, err := parser.ParseAndValidateStatement(input, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ruki statement %q: %v", input, err)
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
|
||||
type navHarness struct {
|
||||
pb *pluginBase
|
||||
config *model.PluginConfig
|
||||
|
|
@ -79,14 +93,8 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
|
|||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
emptyFilter, err := filter.ParseFilter("status = 'done'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
todoFilter, err := filter.ParseFilter("status = 'ready'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
|
|
@ -102,9 +110,10 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 1)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -126,10 +135,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
todoFilter, err := filter.ParseFilter("status = 'ready'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
|
|
@ -145,9 +151,10 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -160,10 +167,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
|
||||
func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, err := filter.ParseFilter("status = 'done'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
|
|
@ -179,9 +183,10 @@ func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 2)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -517,7 +522,7 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -528,9 +533,10 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
navController := newMockNavigationController()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil, schema)
|
||||
|
||||
if !pc.HandleAction(ActionOpenFromPlugin) {
|
||||
t.Error("expected HandleAction(open) to return true when task is selected")
|
||||
|
|
@ -544,7 +550,7 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
|
|||
|
||||
func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, _ := filter.ParseFilter("status = 'done'")
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
|
|
@ -552,9 +558,10 @@ func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
|
|||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
|
||||
|
||||
if pc.HandleAction(ActionOpenFromPlugin) {
|
||||
t.Error("expected false when no task is selected")
|
||||
|
|
@ -567,7 +574,7 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -577,9 +584,10 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
|
||||
|
||||
if !pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected HandleAction(delete) to return true")
|
||||
|
|
@ -592,7 +600,7 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
|
|||
|
||||
func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, _ := filter.ParseFilter("status = 'done'")
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
|
|
@ -600,9 +608,10 @@ func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
|
|||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
|
||||
|
||||
if pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected false when no task is selected")
|
||||
|
|
@ -615,7 +624,7 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -626,12 +635,13 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnDelete(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "cannot delete"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline, schema)
|
||||
|
||||
if pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected false when delete is rejected")
|
||||
|
|
@ -645,7 +655,7 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
|||
|
||||
func TestPluginController_GetNameAndRegistry(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "MyPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -653,9 +663,10 @@ func TestPluginController_GetNameAndRegistry(t *testing.T) {
|
|||
pluginConfig := model.NewPluginConfig("MyPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
if pc.GetPluginName() != "MyPlugin" {
|
||||
t.Errorf("GetPluginName() = %q, want %q", pc.GetPluginName(), "MyPlugin")
|
||||
|
|
@ -671,20 +682,15 @@ func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
inProgressFilter, _ := filter.ParseFilter("status = 'in_progress'")
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
inProgressFilter := mustParseStmt(t, `select where status = "inProgress"`)
|
||||
inProgressAction := mustParseStmt(t, `update where id = id() set status = "inProgress"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{
|
||||
{Name: "Ready", Columns: 1, Filter: readyFilter},
|
||||
{
|
||||
Name: "InProgress", Columns: 1, Filter: inProgressFilter,
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "inProgress"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Name: "InProgress", Columns: 1, Filter: inProgressFilter, Action: inProgressAction},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
|
|
@ -693,12 +699,13 @@ func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
if pc.HandleAction(ActionMoveTaskRight) {
|
||||
t.Error("expected false when move is rejected by gate")
|
||||
|
|
@ -717,19 +724,17 @@ func TestPluginController_HandlePluginAction_Success(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
|
||||
},
|
||||
},
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: markDoneAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -738,9 +743,10 @@ func TestPluginController_HandlePluginAction_Success(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
if !pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected true for successful plugin action")
|
||||
|
|
@ -758,19 +764,17 @@ func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
|
||||
},
|
||||
},
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: markDoneAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -780,12 +784,13 @@ func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
if pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected false when plugin action is rejected by gate")
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 1
|
||||
filter: status = 'backlog'
|
||||
action: status=backlog, tags-=[moved]
|
||||
filter: select where status = "backlog"
|
||||
action: update where id = id() set status="backlog" tags=tags-["moved"]
|
||||
- name: Done
|
||||
columns: 1
|
||||
filter: status = 'done'
|
||||
action: status=done, tags+=[moved]
|
||||
filter: select where status = "done"
|
||||
action: update where id = id() set status="done" tags=tags+["moved"]
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write workflow.yaml: %v", err)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
)
|
||||
|
|
@ -25,6 +26,7 @@ func BuildControllers(
|
|||
plugins []plugin.Plugin,
|
||||
pluginConfigs map[string]*model.PluginConfig,
|
||||
statuslineConfig *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *Controllers {
|
||||
navController := controller.NewNavigationController(app)
|
||||
taskController := controller.NewTaskController(taskStore, mutationGate, navController, statuslineConfig)
|
||||
|
|
@ -39,6 +41,7 @@ func BuildControllers(
|
|||
tp,
|
||||
navController,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,8 +107,11 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
headerConfig, layoutModel := InitHeaderAndLayoutModels()
|
||||
statuslineConfig := InitStatuslineModel(tikiStore)
|
||||
|
||||
// Phase 5.5: Ruki schema (needed by plugin parser and trigger system)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
|
||||
// Phase 6: Plugin system
|
||||
plugins, err := LoadPlugins()
|
||||
plugins, err := LoadPlugins(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -117,7 +120,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
triggerEngine, triggerCount, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName })
|
||||
if err != nil {
|
||||
|
|
@ -138,6 +140,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
plugins,
|
||||
pluginConfigs,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
|
||||
// Phase 8: Input routing
|
||||
|
|
@ -148,6 +151,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
taskStore,
|
||||
gate,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
|
||||
// Phase 9: View factory and layout
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// LoadPlugins loads plugins from disk. Returns an error if workflow files
|
||||
// exist but contain no valid view definitions.
|
||||
func LoadPlugins() ([]plugin.Plugin, error) {
|
||||
plugins, err := plugin.LoadPlugins()
|
||||
func LoadPlugins(schema ruki.Schema) ([]plugin.Plugin, error) {
|
||||
plugins, err := plugin.LoadPlugins(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
478
plugin/action.go
478
plugin/action.go
|
|
@ -1,478 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// LaneAction represents parsed lane actions.
|
||||
type LaneAction struct {
|
||||
Ops []LaneActionOp
|
||||
}
|
||||
|
||||
// LaneActionOp represents a single action operation.
|
||||
type LaneActionOp struct {
|
||||
Field ActionField
|
||||
Operator ActionOperator
|
||||
StrValue string
|
||||
IntValue int
|
||||
Tags []string
|
||||
DependsOn []string
|
||||
DueValue time.Time
|
||||
RecurrenceValue task.Recurrence
|
||||
}
|
||||
|
||||
// ActionField identifies a supported action field.
|
||||
type ActionField string
|
||||
|
||||
const (
|
||||
ActionFieldStatus ActionField = "status"
|
||||
ActionFieldType ActionField = "type"
|
||||
ActionFieldPriority ActionField = "priority"
|
||||
ActionFieldAssignee ActionField = "assignee"
|
||||
ActionFieldPoints ActionField = "points"
|
||||
ActionFieldTags ActionField = "tags"
|
||||
ActionFieldDependsOn ActionField = "dependsOn"
|
||||
ActionFieldDue ActionField = "due"
|
||||
ActionFieldRecurrence ActionField = "recurrence"
|
||||
)
|
||||
|
||||
// ActionOperator identifies a supported action operator.
|
||||
type ActionOperator string
|
||||
|
||||
const (
|
||||
ActionOperatorAssign ActionOperator = "="
|
||||
ActionOperatorAdd ActionOperator = "+="
|
||||
ActionOperatorRemove ActionOperator = "-="
|
||||
)
|
||||
|
||||
// ParseLaneAction parses a lane action string into operations.
|
||||
func ParseLaneAction(input string) (LaneAction, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return LaneAction{}, nil
|
||||
}
|
||||
|
||||
parts, err := splitTopLevelCommas(input)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
|
||||
ops := make([]LaneActionOp, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
return LaneAction{}, fmt.Errorf("empty action segment")
|
||||
}
|
||||
|
||||
field, op, value, err := parseActionSegment(part)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
|
||||
switch field {
|
||||
case ActionFieldTags:
|
||||
if op == ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("tags action only supports += or -=")
|
||||
}
|
||||
tags, err := parseTagsValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
Tags: tags,
|
||||
})
|
||||
case ActionFieldDependsOn:
|
||||
if op == ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("dependsOn action only supports += or -=")
|
||||
}
|
||||
deps, err := parseTagsValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
DependsOn: deps,
|
||||
})
|
||||
case ActionFieldPriority, ActionFieldPoints:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
intValue, err := parseIntValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
if field == ActionFieldPriority && !task.IsValidPriority(intValue) {
|
||||
return LaneAction{}, fmt.Errorf("priority value out of range: %d", intValue)
|
||||
}
|
||||
if field == ActionFieldPoints && !task.IsValidPoints(intValue) {
|
||||
return LaneAction{}, fmt.Errorf("points value out of range: %d", intValue)
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
IntValue: intValue,
|
||||
})
|
||||
case ActionFieldStatus:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
strValue, err := parseStringValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
if _, ok := task.ParseStatus(strValue); !ok {
|
||||
return LaneAction{}, fmt.Errorf("invalid status value %q", strValue)
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
StrValue: strValue,
|
||||
})
|
||||
case ActionFieldType:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
strValue, err := parseStringValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
if _, ok := task.ParseType(strValue); !ok {
|
||||
return LaneAction{}, fmt.Errorf("invalid type value %q", strValue)
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
StrValue: strValue,
|
||||
})
|
||||
case ActionFieldDue:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
dueValue, err := parseDateValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
DueValue: dueValue,
|
||||
})
|
||||
case ActionFieldRecurrence:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
recValue, err := parseRecurrenceValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
RecurrenceValue: recValue,
|
||||
})
|
||||
default:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
strValue, err := parseStringValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
StrValue: strValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return LaneAction{Ops: ops}, nil
|
||||
}
|
||||
|
||||
// ApplyLaneAction applies a parsed action to a task clone.
|
||||
func ApplyLaneAction(src *task.Task, action LaneAction, currentUser string) (*task.Task, error) {
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("task is nil")
|
||||
}
|
||||
|
||||
if len(action.Ops) == 0 {
|
||||
return src.Clone(), nil
|
||||
}
|
||||
|
||||
clone := src.Clone()
|
||||
for _, op := range action.Ops {
|
||||
switch op.Field {
|
||||
case ActionFieldStatus:
|
||||
clone.Status = task.MapStatus(op.StrValue)
|
||||
case ActionFieldType:
|
||||
clone.Type = task.NormalizeType(op.StrValue)
|
||||
case ActionFieldPriority:
|
||||
clone.Priority = op.IntValue
|
||||
case ActionFieldAssignee:
|
||||
assignee := op.StrValue
|
||||
if isCurrentUserToken(assignee) {
|
||||
if strings.TrimSpace(currentUser) == "" {
|
||||
return nil, fmt.Errorf("current user is not available for assignee")
|
||||
}
|
||||
assignee = currentUser
|
||||
}
|
||||
clone.Assignee = assignee
|
||||
case ActionFieldPoints:
|
||||
clone.Points = op.IntValue
|
||||
case ActionFieldTags:
|
||||
clone.Tags = applyTagOperation(clone.Tags, op.Operator, op.Tags)
|
||||
case ActionFieldDependsOn:
|
||||
clone.DependsOn = applyTagOperation(clone.DependsOn, op.Operator, op.DependsOn)
|
||||
case ActionFieldDue:
|
||||
clone.Due = op.DueValue
|
||||
case ActionFieldRecurrence:
|
||||
clone.Recurrence = op.RecurrenceValue
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported action field %q", op.Field)
|
||||
}
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func isCurrentUserToken(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), "CURRENT_USER")
|
||||
}
|
||||
|
||||
func parseActionSegment(segment string) (ActionField, ActionOperator, string, error) {
|
||||
opIdx, op := findOperator(segment)
|
||||
if opIdx == -1 {
|
||||
return "", "", "", fmt.Errorf("action segment missing operator: %q", segment)
|
||||
}
|
||||
|
||||
field := strings.TrimSpace(segment[:opIdx])
|
||||
value := strings.TrimSpace(segment[opIdx+len(op):])
|
||||
if field == "" || value == "" {
|
||||
return "", "", "", fmt.Errorf("invalid action segment: %q", segment)
|
||||
}
|
||||
|
||||
switch strings.ToLower(field) {
|
||||
case "status":
|
||||
return ActionFieldStatus, op, value, nil
|
||||
case "type":
|
||||
return ActionFieldType, op, value, nil
|
||||
case "priority":
|
||||
return ActionFieldPriority, op, value, nil
|
||||
case "assignee":
|
||||
return ActionFieldAssignee, op, value, nil
|
||||
case "points":
|
||||
return ActionFieldPoints, op, value, nil
|
||||
case "tags":
|
||||
return ActionFieldTags, op, value, nil
|
||||
case "dependson":
|
||||
return ActionFieldDependsOn, op, value, nil
|
||||
case "due":
|
||||
return ActionFieldDue, op, value, nil
|
||||
case "recurrence":
|
||||
return ActionFieldRecurrence, op, value, nil
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("unknown action field %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
func findOperator(segment string) (int, ActionOperator) {
|
||||
if idx := strings.Index(segment, "+="); idx != -1 {
|
||||
return idx, ActionOperatorAdd
|
||||
}
|
||||
if idx := strings.Index(segment, "-="); idx != -1 {
|
||||
return idx, ActionOperatorRemove
|
||||
}
|
||||
if idx := strings.Index(segment, "="); idx != -1 {
|
||||
return idx, ActionOperatorAssign
|
||||
}
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// unquote trims whitespace and strips surrounding single or double quotes.
|
||||
func unquote(raw string) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if len(v) >= 2 {
|
||||
if (v[0] == '\'' && v[len(v)-1] == '\'') ||
|
||||
(v[0] == '"' && v[len(v)-1] == '"') {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func parseStringValue(raw string) (string, error) {
|
||||
value := unquote(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("string value is empty")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseDateValue parses a date value for the due field.
|
||||
// Empty string (or empty after unquoting) means clear the due date (returns zero time).
|
||||
// Otherwise parses as YYYY-MM-DD format.
|
||||
func parseDateValue(raw string) (time.Time, error) {
|
||||
value := unquote(raw)
|
||||
|
||||
// Empty string means clear the due date
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
// Parse as date
|
||||
parsed, ok := task.ParseDueDate(value)
|
||||
if !ok {
|
||||
return time.Time{}, fmt.Errorf("invalid date format %q (expected YYYY-MM-DD)", value)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseRecurrenceValue parses a recurrence value from an action expression.
|
||||
// Empty string (or empty after unquoting) clears the recurrence.
|
||||
// Otherwise parses as a cron pattern or display name.
|
||||
func parseRecurrenceValue(raw string) (task.Recurrence, error) {
|
||||
value := unquote(raw)
|
||||
|
||||
if value == "" {
|
||||
return task.RecurrenceNone, nil
|
||||
}
|
||||
|
||||
// try as cron pattern first
|
||||
if r, ok := task.ParseRecurrence(value); ok {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// try as display name
|
||||
r := task.RecurrenceFromDisplay(value)
|
||||
if r != task.RecurrenceNone {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
return task.RecurrenceNone, fmt.Errorf("invalid recurrence value %q", value)
|
||||
}
|
||||
|
||||
func parseIntValue(raw string) (int, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value %q", value)
|
||||
}
|
||||
return intValue, nil
|
||||
}
|
||||
|
||||
func parseTagsValue(raw string) ([]string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
|
||||
return nil, fmt.Errorf("tags value must be in brackets, got %q", value)
|
||||
}
|
||||
inner := strings.TrimSpace(value[1 : len(value)-1])
|
||||
if inner == "" {
|
||||
return nil, fmt.Errorf("tags list is empty")
|
||||
}
|
||||
parts, err := splitTopLevelCommas(inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
tag, err := parseStringValue(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func splitTopLevelCommas(input string) ([]string, error) {
|
||||
var parts []string
|
||||
start := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
bracketDepth := 0
|
||||
|
||||
for i, r := range input {
|
||||
switch r {
|
||||
case '\'':
|
||||
if !inDouble {
|
||||
inSingle = !inSingle
|
||||
}
|
||||
case '"':
|
||||
if !inSingle {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
case '[':
|
||||
if !inSingle && !inDouble {
|
||||
bracketDepth++
|
||||
}
|
||||
case ']':
|
||||
if !inSingle && !inDouble {
|
||||
if bracketDepth == 0 {
|
||||
return nil, fmt.Errorf("unexpected ']' in %q", input)
|
||||
}
|
||||
bracketDepth--
|
||||
}
|
||||
case ',':
|
||||
if !inSingle && !inDouble && bracketDepth == 0 {
|
||||
part := strings.TrimSpace(input[start:i])
|
||||
parts = append(parts, part)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inSingle || inDouble || bracketDepth != 0 {
|
||||
return nil, fmt.Errorf("unterminated quotes or brackets in %q", input)
|
||||
}
|
||||
|
||||
part := strings.TrimSpace(input[start:])
|
||||
parts = append(parts, part)
|
||||
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func applyTagOperation(current []string, op ActionOperator, tags []string) []string {
|
||||
switch op {
|
||||
case ActionOperatorAdd:
|
||||
return addTags(current, tags)
|
||||
case ActionOperatorRemove:
|
||||
return removeTags(current, tags)
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func addTags(current []string, tags []string) []string {
|
||||
existing := make(map[string]bool, len(current))
|
||||
for _, tag := range current {
|
||||
existing[tag] = true
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if !existing[tag] {
|
||||
current = append(current, tag)
|
||||
existing[tag] = true
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func removeTags(current []string, tags []string) []string {
|
||||
toRemove := make(map[string]bool, len(tags))
|
||||
for _, tag := range tags {
|
||||
toRemove[tag] = true
|
||||
}
|
||||
filtered := current[:0]
|
||||
for _, tag := range current {
|
||||
if !toRemove[tag] {
|
||||
filtered = append(filtered, tag)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
|
@ -1,648 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestSplitTopLevelCommas(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "simple split",
|
||||
input: "status=todo, type=bug",
|
||||
want: []string{"status=todo", "type=bug"},
|
||||
},
|
||||
{
|
||||
name: "comma in quotes",
|
||||
input: "assignee='O,Brien', status=done",
|
||||
want: []string{"assignee='O,Brien'", "status=done"},
|
||||
},
|
||||
{
|
||||
name: "comma in brackets",
|
||||
input: "tags+=[one,two], status=done",
|
||||
want: []string{"tags+=[one,two]", "status=done"},
|
||||
},
|
||||
{
|
||||
name: "mixed quotes and brackets",
|
||||
input: `tags+=[one,"two,three"], status=done`,
|
||||
want: []string{`tags+=[one,"two,three"]`, "status=done"},
|
||||
},
|
||||
{
|
||||
name: "unterminated quote",
|
||||
input: "status='todo, type=bug",
|
||||
wantErr: "unterminated quotes or brackets",
|
||||
},
|
||||
{
|
||||
name: "unterminated brackets",
|
||||
input: "tags+=[one,two, status=done",
|
||||
wantErr: "unterminated quotes or brackets",
|
||||
},
|
||||
{
|
||||
name: "unexpected closing bracket",
|
||||
input: "status=todo], type=bug",
|
||||
wantErr: "unexpected ']'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := splitTopLevelCommas(tc.input)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction(t *testing.T) {
|
||||
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review'], dependsOn+=[TIKI-ABC123]")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(action.Ops) != 7 {
|
||||
t.Fatalf("expected 7 ops, got %d", len(action.Ops))
|
||||
}
|
||||
|
||||
gotFields := []ActionField{
|
||||
action.Ops[0].Field,
|
||||
action.Ops[1].Field,
|
||||
action.Ops[2].Field,
|
||||
action.Ops[3].Field,
|
||||
action.Ops[4].Field,
|
||||
action.Ops[5].Field,
|
||||
action.Ops[6].Field,
|
||||
}
|
||||
wantFields := []ActionField{
|
||||
ActionFieldStatus,
|
||||
ActionFieldType,
|
||||
ActionFieldPriority,
|
||||
ActionFieldPoints,
|
||||
ActionFieldAssignee,
|
||||
ActionFieldTags,
|
||||
ActionFieldDependsOn,
|
||||
}
|
||||
if !reflect.DeepEqual(gotFields, wantFields) {
|
||||
t.Fatalf("expected fields %v, got %v", wantFields, gotFields)
|
||||
}
|
||||
|
||||
if action.Ops[0].StrValue != "done" {
|
||||
t.Fatalf("expected status value 'done', got %q", action.Ops[0].StrValue)
|
||||
}
|
||||
if action.Ops[1].StrValue != "bug" {
|
||||
t.Fatalf("expected type value 'bug', got %q", action.Ops[1].StrValue)
|
||||
}
|
||||
if action.Ops[2].IntValue != 2 {
|
||||
t.Fatalf("expected priority 2, got %d", action.Ops[2].IntValue)
|
||||
}
|
||||
if action.Ops[3].IntValue != 3 {
|
||||
t.Fatalf("expected points 3, got %d", action.Ops[3].IntValue)
|
||||
}
|
||||
if action.Ops[4].StrValue != "Alice" {
|
||||
t.Fatalf("expected assignee Alice, got %q", action.Ops[4].StrValue)
|
||||
}
|
||||
if !reflect.DeepEqual(action.Ops[5].Tags, []string{"frontend", "needs review"}) {
|
||||
t.Fatalf("expected tags [frontend needs review], got %v", action.Ops[5].Tags)
|
||||
}
|
||||
if !reflect.DeepEqual(action.Ops[6].DependsOn, []string{"TIKI-ABC123"}) {
|
||||
t.Fatalf("expected dependsOn [TIKI-ABC123], got %v", action.Ops[6].DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty segment",
|
||||
input: "status=done,,type=bug",
|
||||
wantErr: "empty action segment",
|
||||
},
|
||||
{
|
||||
name: "missing operator",
|
||||
input: "statusdone",
|
||||
wantErr: "missing operator",
|
||||
},
|
||||
{
|
||||
name: "tags assign not allowed",
|
||||
input: "tags=[one]",
|
||||
wantErr: "tags action only supports",
|
||||
},
|
||||
{
|
||||
name: "status add not allowed",
|
||||
input: "status+=done",
|
||||
wantErr: "status action only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
input: "owner=me",
|
||||
wantErr: "unknown action field",
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
input: "status=unknown",
|
||||
wantErr: "invalid status value",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
input: "type=unknown",
|
||||
wantErr: "invalid type value",
|
||||
},
|
||||
{
|
||||
name: "priority out of range",
|
||||
input: "priority=10",
|
||||
wantErr: "priority value out of range",
|
||||
},
|
||||
{
|
||||
name: "points out of range",
|
||||
input: "points=-1",
|
||||
wantErr: "points value out of range",
|
||||
},
|
||||
{
|
||||
name: "tags missing brackets",
|
||||
input: "tags+={one}",
|
||||
wantErr: "tags value must be in brackets",
|
||||
},
|
||||
{
|
||||
name: "dependsOn assign not allowed",
|
||||
input: "dependsOn=[TIKI-ABC123]",
|
||||
wantErr: "dependsOn action only supports",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseLaneAction(tc.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
Tags: []string{"existing"},
|
||||
DependsOn: []string{"TIKI-AAA111"},
|
||||
Assignee: "Bob",
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee=Alice, tags+=[moved], dependsOn+=[TIKI-BBB222]")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected parse error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected apply error: %v", err)
|
||||
}
|
||||
|
||||
if updated.Status != task.StatusDone {
|
||||
t.Fatalf("expected status done, got %v", updated.Status)
|
||||
}
|
||||
if updated.Type != task.TypeBug {
|
||||
t.Fatalf("expected type bug, got %v", updated.Type)
|
||||
}
|
||||
if updated.Priority != 2 {
|
||||
t.Fatalf("expected priority 2, got %d", updated.Priority)
|
||||
}
|
||||
if updated.Points != 3 {
|
||||
t.Fatalf("expected points 3, got %d", updated.Points)
|
||||
}
|
||||
if updated.Assignee != "Alice" {
|
||||
t.Fatalf("expected assignee Alice, got %q", updated.Assignee)
|
||||
}
|
||||
if !reflect.DeepEqual(updated.Tags, []string{"existing", "moved"}) {
|
||||
t.Fatalf("expected tags [existing moved], got %v", updated.Tags)
|
||||
}
|
||||
if !reflect.DeepEqual(updated.DependsOn, []string{"TIKI-AAA111", "TIKI-BBB222"}) {
|
||||
t.Fatalf("expected dependsOn [TIKI-AAA111 TIKI-BBB222], got %v", updated.DependsOn)
|
||||
}
|
||||
if base.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected base task unchanged, got %v", base.Status)
|
||||
}
|
||||
if !reflect.DeepEqual(base.Tags, []string{"existing"}) {
|
||||
t.Fatalf("expected base tags unchanged, got %v", base.Tags)
|
||||
}
|
||||
if !reflect.DeepEqual(base.DependsOn, []string{"TIKI-AAA111"}) {
|
||||
t.Fatalf("expected base dependsOn unchanged, got %v", base.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_InvalidResult(t *testing.T) {
|
||||
// ApplyLaneAction no longer validates the result — the gate does that
|
||||
// at persistence time. This test verifies the action is applied as-is.
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
}
|
||||
|
||||
action := LaneAction{
|
||||
Ops: []LaneActionOp{
|
||||
{
|
||||
Field: ActionFieldPriority,
|
||||
Operator: ActionOperatorAssign,
|
||||
IntValue: 99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Priority != 99 {
|
||||
t.Errorf("expected priority 99, got %d", result.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_AssigneeCurrentUser(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
Assignee: "Bob",
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction("assignee=CURRENT_USER")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected parse error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ApplyLaneAction(base, action, "Alex")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected apply error: %v", err)
|
||||
}
|
||||
if updated.Assignee != "Alex" {
|
||||
t.Fatalf("expected assignee Alex, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_AssigneeCurrentUserMissing(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction("assignee=CURRENT_USER")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected parse error: %v", err)
|
||||
}
|
||||
|
||||
_, err = ApplyLaneAction(base, action, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing current user")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "current user") {
|
||||
t.Fatalf("expected current user error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_Due(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
checkFunc func(*testing.T, LaneAction)
|
||||
}{
|
||||
{
|
||||
name: "due with valid date",
|
||||
input: "due=2026-03-16",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
if op.Field != ActionFieldDue {
|
||||
t.Errorf("expected field due, got %v", op.Field)
|
||||
}
|
||||
if op.Operator != ActionOperatorAssign {
|
||||
t.Errorf("expected operator =, got %v", op.Operator)
|
||||
}
|
||||
expectedDate := "2026-03-16"
|
||||
gotDate := op.DueValue.Format(task.DateFormat)
|
||||
if gotDate != expectedDate {
|
||||
t.Errorf("expected date %v, got %v", expectedDate, gotDate)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "due with quoted date",
|
||||
input: "due='2026-03-16'",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
expectedDate := "2026-03-16"
|
||||
gotDate := op.DueValue.Format(task.DateFormat)
|
||||
if gotDate != expectedDate {
|
||||
t.Errorf("expected date %v, got %v", expectedDate, gotDate)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "due with empty string (clear)",
|
||||
input: "due=''",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
if !op.DueValue.IsZero() {
|
||||
t.Errorf("expected zero time for empty string, got %v", op.DueValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "due with invalid date format",
|
||||
input: "due=03/16/2026",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "due with += operator",
|
||||
input: "due+=2026-03-16",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "due with -= operator",
|
||||
input: "due-=2026-03-16",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action, err := ParseLaneAction(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseLaneAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.checkFunc != nil {
|
||||
tt.checkFunc(t, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_Recurrence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
checkFunc func(*testing.T, LaneAction)
|
||||
}{
|
||||
{
|
||||
name: "recurrence with cron pattern",
|
||||
input: "recurrence='0 0 * * MON'",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
if op.Field != ActionFieldRecurrence {
|
||||
t.Errorf("expected field recurrence, got %v", op.Field)
|
||||
}
|
||||
if op.RecurrenceValue != task.Recurrence("0 0 * * MON") {
|
||||
t.Errorf("expected '0 0 * * MON', got %q", op.RecurrenceValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recurrence with display name",
|
||||
input: "recurrence=Daily",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
op := action.Ops[0]
|
||||
if op.RecurrenceValue != task.RecurrenceDaily {
|
||||
t.Errorf("expected daily cron, got %q", op.RecurrenceValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recurrence clear with empty string",
|
||||
input: "recurrence=''",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
op := action.Ops[0]
|
||||
if op.RecurrenceValue != task.RecurrenceNone {
|
||||
t.Errorf("expected empty recurrence, got %q", op.RecurrenceValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recurrence invalid value",
|
||||
input: "recurrence=biweekly",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "recurrence += not allowed",
|
||||
input: "recurrence+=Daily",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action, err := ParseLaneAction(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseLaneAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.checkFunc != nil {
|
||||
tt.checkFunc(t, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_Recurrence(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TIKI-TEST01",
|
||||
Title: "Test Task",
|
||||
Type: task.TypeStory,
|
||||
Status: "backlog",
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
t.Run("set recurrence", func(t *testing.T) {
|
||||
action, err := ParseLaneAction("recurrence='0 0 * * MON'")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
if result.Recurrence != task.Recurrence("0 0 * * MON") {
|
||||
t.Errorf("expected '0 0 * * MON', got %q", result.Recurrence)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear recurrence", func(t *testing.T) {
|
||||
baseWithRec := base.Clone()
|
||||
baseWithRec.Recurrence = "0 0 * * MON"
|
||||
|
||||
action, err := ParseLaneAction("recurrence=''")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
result, err := ApplyLaneAction(baseWithRec, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
if result.Recurrence != task.RecurrenceNone {
|
||||
t.Errorf("expected empty recurrence, got %q", result.Recurrence)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_Due(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TIKI-TEST01",
|
||||
Title: "Test Task",
|
||||
Type: task.TypeStory,
|
||||
Status: "backlog",
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
t.Run("set due date", func(t *testing.T) {
|
||||
action, err := ParseLaneAction("due=2026-03-16")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
expectedDate := "2026-03-16"
|
||||
gotDate := result.Due.Format(task.DateFormat)
|
||||
if gotDate != expectedDate {
|
||||
t.Errorf("expected due date %v, got %v", expectedDate, gotDate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear due date", func(t *testing.T) {
|
||||
baseWithDue := base.Clone()
|
||||
baseWithDue.Due, _ = time.Parse(task.DateFormat, "2026-03-16")
|
||||
|
||||
action, err := ParseLaneAction("due=''")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := ApplyLaneAction(baseWithDue, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
if !result.Due.IsZero() {
|
||||
t.Errorf("expected zero due date, got %v", result.Due)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseLaneAction_EmptyString(t *testing.T) {
|
||||
action, err := ParseLaneAction("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(action.Ops) != 0 {
|
||||
t.Errorf("expected 0 ops for empty input, got %d", len(action.Ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_InvalidInteger(t *testing.T) {
|
||||
_, err := ParseLaneAction("priority=abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-integer priority")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid integer") {
|
||||
t.Errorf("expected 'invalid integer' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_NilTask(t *testing.T) {
|
||||
action := LaneAction{Ops: []LaneActionOp{{Field: ActionFieldStatus, Operator: ActionOperatorAssign, StrValue: "done"}}}
|
||||
_, err := ApplyLaneAction(nil, action, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil task")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "task is nil") {
|
||||
t.Errorf("expected 'task is nil' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_NoOps(t *testing.T) {
|
||||
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
|
||||
result, err := ApplyLaneAction(base, LaneAction{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == base {
|
||||
t.Error("expected clone, not original pointer")
|
||||
}
|
||||
if result.Title != "Task" {
|
||||
t.Errorf("expected title 'Task', got %q", result.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_UnsupportedField(t *testing.T) {
|
||||
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
|
||||
action := LaneAction{Ops: []LaneActionOp{{Field: "bogus", Operator: ActionOperatorAssign, StrValue: "x"}}}
|
||||
_, err := ApplyLaneAction(base, action, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported action field") {
|
||||
t.Errorf("expected 'unsupported action field' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package plugin
|
|||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// Plugin interface defines the common methods for all plugins
|
||||
|
|
@ -64,7 +64,6 @@ func (p *BasePlugin) IsDefault() bool {
|
|||
type TikiPlugin struct {
|
||||
BasePlugin
|
||||
Lanes []TikiLane // lane definitions for this plugin
|
||||
Sort []SortRule // parsed sort rules (nil = default sort)
|
||||
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
|
||||
Actions []PluginAction // shortcut actions applied to the selected task
|
||||
TaskID string // optional tiki associated with this plugin (code-only, not from workflow config)
|
||||
|
|
@ -89,7 +88,7 @@ type PluginActionConfig struct {
|
|||
type PluginAction struct {
|
||||
Rune rune
|
||||
Label string
|
||||
Action LaneAction
|
||||
Action *ruki.ValidatedStatement
|
||||
}
|
||||
|
||||
// PluginLaneConfig represents a lane in YAML or config definitions.
|
||||
|
|
@ -106,6 +105,6 @@ type TikiLane struct {
|
|||
Name string
|
||||
Columns int
|
||||
Width int // lane width as a percentage (0 = equal share of remaining space)
|
||||
Filter filter.FilterExpr
|
||||
Action LaneAction
|
||||
Filter *ruki.ValidatedStatement
|
||||
Action *ruki.ValidatedStatement
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/duration"
|
||||
)
|
||||
|
||||
// durationPattern matches a number followed by a duration unit, with optional plural "s".
|
||||
var durationPattern = regexp.MustCompile(`^(\d+)(` + duration.Pattern() + `)s?$`)
|
||||
|
||||
// IsDurationLiteral checks if a string is a valid duration literal.
|
||||
func IsDurationLiteral(s string) bool {
|
||||
return durationPattern.MatchString(strings.ToLower(s))
|
||||
}
|
||||
|
||||
// ParseDuration parses a duration literal like "24hour" or "1week".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
val, unit, err := duration.Parse(strings.ToLower(s))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
return duration.ToDuration(val, unit)
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// parseComparison parses comparison expressions like: field op value
|
||||
func (p *filterParser) parseComparison() (FilterExpr, error) {
|
||||
// Parse left side (typically a field name or time expression)
|
||||
leftValue, leftIsTimeExpr, err := p.parseValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get operator
|
||||
tok := p.current()
|
||||
if tok.Type != TokenOperator {
|
||||
return nil, fmt.Errorf("expected comparison operator, got %s", tok.Value)
|
||||
}
|
||||
op := tok.Value
|
||||
p.advance()
|
||||
|
||||
// Parse right side
|
||||
rightValue, rightIsTimeExpr, err := p.parseValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build comparison expression
|
||||
// If left side is a time expression like "NOW - CreatedAt", we need to handle it specially
|
||||
if leftIsTimeExpr {
|
||||
leftTimeExpr, _ := leftValue.(*TimeExpr)
|
||||
// The comparison becomes: (NOW - CreatedAt) < 24hour
|
||||
// which means: time.Since(CreatedAt) < 24hour
|
||||
return &CompareExpr{
|
||||
Field: "time_expr",
|
||||
Op: op,
|
||||
Value: &timeExprCompare{left: leftTimeExpr, right: rightValue},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Normal field comparison
|
||||
fieldName, ok := leftValue.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected field name on left side of comparison")
|
||||
}
|
||||
|
||||
// If right side is a time expression, wrap it
|
||||
if rightIsTimeExpr {
|
||||
return &CompareExpr{
|
||||
Field: fieldName,
|
||||
Op: op,
|
||||
Value: rightValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &CompareExpr{
|
||||
Field: fieldName,
|
||||
Op: op,
|
||||
Value: rightValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseValueGeneric parses a value with optional time expression support
|
||||
// allowTimeExpr controls whether to parse time expressions like "NOW - 24hour"
|
||||
// Returns the value and whether it's a time expression
|
||||
func (p *filterParser) parseValueGeneric(allowTimeExpr bool) (interface{}, bool, error) {
|
||||
tok := p.current()
|
||||
|
||||
switch tok.Type {
|
||||
case TokenString:
|
||||
p.advance()
|
||||
return tok.Value, false, nil
|
||||
|
||||
case TokenNumber:
|
||||
p.advance()
|
||||
num, err := strconv.Atoi(tok.Value)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("invalid number: %s", tok.Value)
|
||||
}
|
||||
return num, false, nil
|
||||
|
||||
case TokenDuration:
|
||||
if !allowTimeExpr {
|
||||
return nil, false, fmt.Errorf("duration not allowed in this context")
|
||||
}
|
||||
p.advance()
|
||||
dur, err := ParseDuration(tok.Value)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &DurationValue{Duration: dur}, false, nil
|
||||
|
||||
case TokenIdent:
|
||||
ident := tok.Value
|
||||
p.advance()
|
||||
|
||||
// Check if this is a time expression (only if allowed)
|
||||
if allowTimeExpr && isTimeField(ident) {
|
||||
// Check if followed by + or -
|
||||
if p.current().Type == TokenOperator {
|
||||
opTok := p.current()
|
||||
if opTok.Value == "+" || opTok.Value == "-" {
|
||||
p.advance()
|
||||
// Parse the operand (duration or another time field)
|
||||
operand, _, err := p.parseTimeOperand()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &TimeExpr{Base: ident, Op: opTok.Value, Operand: operand}, true, nil
|
||||
}
|
||||
}
|
||||
// Just a time field reference without arithmetic
|
||||
return &TimeExpr{Base: ident}, true, nil
|
||||
}
|
||||
|
||||
// Regular identifier (field name or special value like CURRENT_USER)
|
||||
return ident, false, nil
|
||||
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unexpected token in value: %s", tok.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// parseValue parses a value for comparisons (allows time expressions)
|
||||
func (p *filterParser) parseValue() (interface{}, bool, error) {
|
||||
return p.parseValueGeneric(true)
|
||||
}
|
||||
|
||||
// parseTimeOperand parses the operand of a time expression (duration or field name)
|
||||
func (p *filterParser) parseTimeOperand() (interface{}, bool, error) {
|
||||
tok := p.current()
|
||||
|
||||
switch tok.Type {
|
||||
case TokenDuration:
|
||||
p.advance()
|
||||
dur, err := ParseDuration(tok.Value)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return dur, false, nil
|
||||
|
||||
case TokenIdent:
|
||||
ident := tok.Value
|
||||
p.advance()
|
||||
|
||||
// Time field names
|
||||
if isTimeField(ident) {
|
||||
return ident, true, nil
|
||||
}
|
||||
|
||||
return nil, false, fmt.Errorf("expected duration or time field, got: %s", ident)
|
||||
|
||||
default:
|
||||
return nil, false, fmt.Errorf("expected duration or time field, got: %s", tok.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// parseInExpr parses: field IN [val1, val2, ...] or field NOT IN [...]
|
||||
// This is called when we detect the IN pattern during primary expression parsing
|
||||
func (p *filterParser) parseInExpr(fieldName string, isNotIn bool) (FilterExpr, error) {
|
||||
// Expect opening bracket
|
||||
if err := p.expect(TokenLBracket); err != nil {
|
||||
return nil, fmt.Errorf("expected '[' after IN: %w", err)
|
||||
}
|
||||
|
||||
// Parse list of values
|
||||
var values []interface{}
|
||||
|
||||
// Handle empty list
|
||||
if p.current().Type == TokenRBracket {
|
||||
p.advance()
|
||||
return &InExpr{Field: fieldName, Not: isNotIn, Values: values}, nil
|
||||
}
|
||||
|
||||
for {
|
||||
// Parse a value (string, number, or identifier like CURRENT_USER)
|
||||
val, err := p.parseListValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, val)
|
||||
|
||||
// Check for comma (more values) or closing bracket (done)
|
||||
tok := p.current()
|
||||
if tok.Type == TokenRBracket {
|
||||
p.advance()
|
||||
break
|
||||
}
|
||||
if tok.Type == TokenComma {
|
||||
p.advance()
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("expected ',' or ']' in list, got: %s", tok.Value)
|
||||
}
|
||||
|
||||
return &InExpr{Field: fieldName, Not: isNotIn, Values: values}, nil
|
||||
}
|
||||
|
||||
// parseListValue parses a single value in a list (string, number, or identifier)
|
||||
// Does not allow durations or time expressions
|
||||
func (p *filterParser) parseListValue() (interface{}, error) {
|
||||
val, _, err := p.parseValueGeneric(false)
|
||||
return val, err
|
||||
}
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// FilterExpr represents a filter expression that can be evaluated against a task
|
||||
type FilterExpr interface {
|
||||
Evaluate(task *task.Task, now time.Time, currentUser string) bool
|
||||
}
|
||||
|
||||
// BinaryExpr represents AND, OR operations
|
||||
type BinaryExpr struct {
|
||||
Op string // "AND", "OR"
|
||||
Left FilterExpr
|
||||
Right FilterExpr
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr
|
||||
func (b *BinaryExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
switch strings.ToUpper(b.Op) {
|
||||
case "AND":
|
||||
return b.Left.Evaluate(task, now, currentUser) && b.Right.Evaluate(task, now, currentUser)
|
||||
case "OR":
|
||||
return b.Left.Evaluate(task, now, currentUser) || b.Right.Evaluate(task, now, currentUser)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UnaryExpr represents NOT operation
|
||||
type UnaryExpr struct {
|
||||
Op string // "NOT"
|
||||
Expr FilterExpr
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr
|
||||
func (u *UnaryExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
if strings.ToUpper(u.Op) == "NOT" {
|
||||
return !u.Expr.Evaluate(task, now, currentUser)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CompareExpr represents comparisons like status = 'ready' or Priority < 3
|
||||
type CompareExpr struct {
|
||||
Field string // "status", "type", "assignee", "priority", "points", "createdat", "updatedat", "tags"
|
||||
Op string // "=", "==", "!=", ">", "<", ">=", "<="
|
||||
Value interface{} // string, int, or TimeExpr
|
||||
}
|
||||
|
||||
// InExpr represents IN/NOT IN operations like: tags IN ['ui', 'charts', 'viz']
|
||||
type InExpr struct {
|
||||
Field string // "status", "type", "tags", etc.
|
||||
Not bool // true for NOT IN, false for IN
|
||||
Values []interface{} // List of values to check against (strings, ints, etc.)
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr for InExpr
|
||||
func (i *InExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
// Handle CURRENT_USER and normalize status literals in the values list
|
||||
isStatus := strings.ToLower(i.Field) == "status"
|
||||
resolvedValues := make([]interface{}, len(i.Values))
|
||||
for idx, val := range i.Values {
|
||||
if strVal, ok := val.(string); ok && strings.ToUpper(strVal) == "CURRENT_USER" {
|
||||
resolvedValues[idx] = currentUser
|
||||
} else if isStatus {
|
||||
if strVal, ok := val.(string); ok {
|
||||
resolvedValues[idx] = string(workflow.NormalizeStatusKey(strVal))
|
||||
} else {
|
||||
resolvedValues[idx] = val
|
||||
}
|
||||
} else {
|
||||
resolvedValues[idx] = val
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for array fields (tags, dependsOn)
|
||||
fieldLower := strings.ToLower(i.Field)
|
||||
if fieldLower == "tags" || fieldLower == "tag" || fieldLower == "dependson" {
|
||||
var arrayField []string
|
||||
switch fieldLower {
|
||||
case "tags", "tag":
|
||||
arrayField = task.Tags
|
||||
case "dependson":
|
||||
arrayField = task.DependsOn
|
||||
}
|
||||
result := evaluateTagsInComparison(arrayField, resolvedValues)
|
||||
if i.Not {
|
||||
return !result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// For non-array fields, check if field value is in the list
|
||||
fieldValue := getTaskAttribute(task, i.Field)
|
||||
result := valueInList(fieldValue, resolvedValues)
|
||||
if i.Not {
|
||||
return !result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr for CompareExpr
|
||||
func (c *CompareExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
// Handle time expression comparisons (e.g., NOW - CreatedAt < 24hour)
|
||||
if c.Field == "time_expr" {
|
||||
return c.evaluateTimeExpr(task, now)
|
||||
}
|
||||
|
||||
fieldValue := getTaskAttribute(task, c.Field)
|
||||
compareValue := c.Value
|
||||
|
||||
// Handle CURRENT_USER special value
|
||||
if strVal, ok := compareValue.(string); ok && strings.ToUpper(strVal) == "CURRENT_USER" {
|
||||
compareValue = currentUser
|
||||
}
|
||||
|
||||
// normalize status literals so legacy forms (e.g. "in_progress") match camelCase keys
|
||||
if strings.ToLower(c.Field) == "status" {
|
||||
if strVal, ok := compareValue.(string); ok {
|
||||
compareValue = string(workflow.NormalizeStatusKey(strVal))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle TimeExpr (for NOW - CreatedAt type comparisons)
|
||||
if timeExpr, ok := compareValue.(*TimeExpr); ok {
|
||||
compareValue = timeExpr.Evaluate(task, now)
|
||||
}
|
||||
|
||||
// Handle DurationValue
|
||||
if dv, ok := compareValue.(*DurationValue); ok {
|
||||
compareValue = dv.Duration
|
||||
}
|
||||
|
||||
// Handle array fields specially - check if value is in the list
|
||||
fieldLower := strings.ToLower(c.Field)
|
||||
if fieldLower == "tags" || fieldLower == "tag" {
|
||||
return evaluateTagComparison(task.Tags, c.Op, compareValue)
|
||||
}
|
||||
if fieldLower == "dependson" {
|
||||
return evaluateTagComparison(task.DependsOn, c.Op, compareValue)
|
||||
}
|
||||
|
||||
return compare(fieldValue, c.Op, compareValue)
|
||||
}
|
||||
|
||||
// evaluateTimeExpr handles time expression comparisons like "NOW - CreatedAt < 24hour"
|
||||
func (c *CompareExpr) evaluateTimeExpr(task *task.Task, now time.Time) bool {
|
||||
tec, ok := c.Value.(*timeExprCompare)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
leftValue := tec.left.Evaluate(task, now)
|
||||
rightValue := tec.right
|
||||
|
||||
// Handle DurationValue
|
||||
if dv, ok := rightValue.(*DurationValue); ok {
|
||||
rightValue = dv.Duration
|
||||
}
|
||||
|
||||
return compare(leftValue, c.Op, rightValue)
|
||||
}
|
||||
|
||||
// TimeExpr represents time arithmetic like NOW - 24hour or NOW - CreatedAt
|
||||
type TimeExpr struct {
|
||||
Base string // "NOW", "CreatedAt", "UpdatedAt", "Due"
|
||||
Op string // "+", "-"
|
||||
Operand interface{} // time.Duration or field name string
|
||||
}
|
||||
|
||||
// Evaluate returns the computed time or duration value
|
||||
func (t *TimeExpr) Evaluate(task *task.Task, now time.Time) interface{} {
|
||||
var baseTime time.Time
|
||||
|
||||
switch strings.ToLower(t.Base) {
|
||||
case "now":
|
||||
baseTime = now
|
||||
case "createdat":
|
||||
baseTime = task.CreatedAt
|
||||
case "updatedat":
|
||||
baseTime = task.UpdatedAt
|
||||
case "due":
|
||||
if task.Due.IsZero() {
|
||||
return nil
|
||||
}
|
||||
baseTime = task.Due
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.Op == "" {
|
||||
return baseTime
|
||||
}
|
||||
|
||||
// Handle duration operand
|
||||
if dur, ok := t.Operand.(time.Duration); ok {
|
||||
if t.Op == "-" {
|
||||
return baseTime.Add(-dur)
|
||||
}
|
||||
return baseTime.Add(dur)
|
||||
}
|
||||
|
||||
// Handle field name operand (e.g., NOW - CreatedAt returns duration)
|
||||
if fieldName, ok := t.Operand.(string); ok {
|
||||
var otherTime time.Time
|
||||
switch strings.ToLower(fieldName) {
|
||||
case "now":
|
||||
otherTime = now
|
||||
case "createdat":
|
||||
otherTime = task.CreatedAt
|
||||
case "updatedat":
|
||||
otherTime = task.UpdatedAt
|
||||
case "due":
|
||||
if task.Due.IsZero() {
|
||||
return nil
|
||||
}
|
||||
otherTime = task.Due
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.Op == "-" {
|
||||
return baseTime.Sub(otherTime)
|
||||
}
|
||||
// Addition of times doesn't make sense, return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return baseTime
|
||||
}
|
||||
|
||||
// DurationValue represents a parsed duration for comparison
|
||||
type DurationValue struct {
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// timeExprCompare wraps a time expression comparison for evaluation
|
||||
type timeExprCompare struct {
|
||||
left *TimeExpr
|
||||
right interface{}
|
||||
}
|
||||
|
||||
// getTaskAttribute returns the value of a task field by name
|
||||
func getTaskAttribute(task *task.Task, field string) interface{} {
|
||||
switch strings.ToLower(field) {
|
||||
case "status":
|
||||
return string(task.Status)
|
||||
case "type":
|
||||
return string(task.Type)
|
||||
case "assignee":
|
||||
return task.Assignee
|
||||
case "priority":
|
||||
return task.Priority
|
||||
case "points":
|
||||
return task.Points
|
||||
case "createdat":
|
||||
return task.CreatedAt
|
||||
case "updatedat":
|
||||
return task.UpdatedAt
|
||||
case "tags":
|
||||
return task.Tags
|
||||
case "dependson":
|
||||
return task.DependsOn
|
||||
case "due":
|
||||
return task.Due
|
||||
case "recurrence":
|
||||
return string(task.Recurrence)
|
||||
case "id":
|
||||
return task.ID
|
||||
case "title":
|
||||
return task.Title
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateTagComparison checks if a tag matches the comparison
|
||||
func evaluateTagComparison(tags []string, op string, value interface{}) bool {
|
||||
strVal, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any tag matches
|
||||
found := false
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, strVal) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "=", "==":
|
||||
return found
|
||||
case "!=":
|
||||
return !found
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateTagsInComparison checks if ANY task tag matches ANY value in the list
|
||||
// Semantics: task.Tags ∩ values != ∅
|
||||
func evaluateTagsInComparison(taskTags []string, values []interface{}) bool {
|
||||
for _, taskTag := range taskTags {
|
||||
for _, val := range values {
|
||||
if strVal, ok := val.(string); ok {
|
||||
if strings.EqualFold(taskTag, strVal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// valueInList checks if a single value exists in a list of values
|
||||
func valueInList(fieldValue interface{}, values []interface{}) bool {
|
||||
for _, val := range values {
|
||||
// String comparison (case-insensitive)
|
||||
if fvStr, ok := fieldValue.(string); ok {
|
||||
if valStr, ok := val.(string); ok {
|
||||
if strings.EqualFold(fvStr, valStr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Integer comparison
|
||||
if fvInt, ok := fieldValue.(int); ok {
|
||||
if valInt, ok := val.(int); ok {
|
||||
if fvInt == valInt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Direct equality for other types
|
||||
if fieldValue == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compare compares two values using the given operator
|
||||
func compare(left interface{}, op string, right interface{}) bool {
|
||||
// Normalize operator
|
||||
if op == "==" {
|
||||
op = "="
|
||||
}
|
||||
|
||||
// String comparison
|
||||
if leftStr, ok := left.(string); ok {
|
||||
rightStr, ok := right.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return compareStrings(leftStr, op, rightStr)
|
||||
}
|
||||
|
||||
// Integer comparison
|
||||
if leftInt, ok := left.(int); ok {
|
||||
rightInt, ok := right.(int)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return compareInts(leftInt, op, rightInt)
|
||||
}
|
||||
|
||||
// Time comparison
|
||||
if leftTime, ok := left.(time.Time); ok {
|
||||
if rightTime, ok := right.(time.Time); ok {
|
||||
return compareTimes(leftTime, op, rightTime)
|
||||
}
|
||||
// Coerce string to date for comparison (e.g., due = '2026-03-16')
|
||||
if rightStr, ok := right.(string); ok {
|
||||
if parsed, ok := task.ParseDueDate(rightStr); ok {
|
||||
return compareTimes(leftTime, op, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration comparison
|
||||
if leftDur, ok := left.(time.Duration); ok {
|
||||
if rightDur, ok := right.(time.Duration); ok {
|
||||
return compareDurations(leftDur, op, rightDur)
|
||||
}
|
||||
// Compare duration with DurationValue
|
||||
if rightDurVal, ok := right.(*DurationValue); ok {
|
||||
return compareDurations(leftDur, op, rightDurVal.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func compareStrings(left, op, right string) bool {
|
||||
// Case-insensitive comparison
|
||||
left = strings.ToLower(left)
|
||||
right = strings.ToLower(right)
|
||||
|
||||
switch op {
|
||||
case "=":
|
||||
return left == right
|
||||
case "!=":
|
||||
return left != right
|
||||
case ">":
|
||||
return left > right
|
||||
case "<":
|
||||
return left < right
|
||||
case ">=":
|
||||
return left >= right
|
||||
case "<=":
|
||||
return left <= right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareInts(left int, op string, right int) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return left == right
|
||||
case "!=":
|
||||
return left != right
|
||||
case ">":
|
||||
return left > right
|
||||
case "<":
|
||||
return left < right
|
||||
case ">=":
|
||||
return left >= right
|
||||
case "<=":
|
||||
return left <= right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareTimes(left time.Time, op string, right time.Time) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return left.Equal(right)
|
||||
case "!=":
|
||||
return !left.Equal(right)
|
||||
case ">":
|
||||
return left.After(right)
|
||||
case "<":
|
||||
return left.Before(right)
|
||||
case ">=":
|
||||
return left.After(right) || left.Equal(right)
|
||||
case "<=":
|
||||
return left.Before(right) || left.Equal(right)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareDurations(left time.Duration, op string, right time.Duration) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return left == right
|
||||
case "!=":
|
||||
return left != right
|
||||
case ">":
|
||||
return left > right
|
||||
case "<":
|
||||
return left < right
|
||||
case ">=":
|
||||
return left >= right
|
||||
case "<=":
|
||||
return left <= right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TestDoubleNegation tests that NOT NOT works correctly
|
||||
func TestDoubleNegation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "double negation - should match",
|
||||
expr: "NOT NOT status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true, // NOT NOT true = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "double negation - should not match",
|
||||
expr: "NOT NOT status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: false, // NOT NOT false = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "double negation with parentheses",
|
||||
expr: "NOT (NOT (status = 'ready'))",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple negation - odd number",
|
||||
expr: "NOT NOT NOT status = 'done'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true, // NOT NOT NOT false = NOT NOT true = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "triple negation - cancels to NOT",
|
||||
expr: "NOT NOT NOT status = 'done'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: false, // NOT NOT NOT true = NOT NOT false = NOT true = false
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyFilter tests handling of empty filter expressions
|
||||
func TestEmptyFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
expect bool // empty filter should match all tasks
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
expr: "",
|
||||
expect: true, // nil filter means no filtering
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
expr: " ",
|
||||
expect: true, // trimmed to empty, should be nil filter
|
||||
},
|
||||
}
|
||||
|
||||
task := &task.Task{Status: task.StatusReady}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
// Empty filter returns nil
|
||||
if filter == nil {
|
||||
// Nil filter means match all - this is correct behavior
|
||||
return
|
||||
}
|
||||
|
||||
result := filter.Evaluate(task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %q", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplexNOTExpressions tests NOT with various complex expressions
|
||||
func TestComplexNOTExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "NOT with AND - both conditions true",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: false, // NOT (true AND true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with AND - one condition false",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // NOT (true AND false) = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "NOT with OR - both conditions true",
|
||||
expr: "NOT (status = 'ready' OR type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: false, // NOT (true OR true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with OR - one condition true",
|
||||
expr: "NOT (status = 'ready' OR type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false, // NOT (true OR false) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with OR - both conditions false",
|
||||
expr: "NOT (status = 'ready' OR type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
|
||||
expect: true, // NOT (false OR false) = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "NOT with complex mixed expression",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: false, // NOT ((true AND true) OR false) = NOT (true OR false) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with complex mixed expression - alternative match",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory},
|
||||
expect: false, // NOT ((false AND false) OR true) = NOT (false OR true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with complex mixed expression - no match",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
|
||||
expect: true, // NOT ((false AND false) OR false) = NOT false = true
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllOperatorsCombined tests expressions using all available operators
|
||||
func TestAllOperatorsCombined(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "all operators - all conditions match",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Tags: []string{"active"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "all operators - NOT fails",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 5, Tags: []string{"active"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "all operators - IN fails",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: "epic", Priority: 2, Tags: []string{"active"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "all operators - NOT IN fails",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Tags: []string{"deprecated"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "complex with comparisons and IN",
|
||||
expr: "(priority >= 3 AND priority <= 5) OR (status IN ['ready', 'inProgress'] AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
|
||||
expect: true, // Second part matches
|
||||
},
|
||||
{
|
||||
name: "complex with multiple NOT",
|
||||
expr: "NOT status = 'done' AND NOT type = 'epic' AND NOT tags IN ['deprecated', 'archived']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Tags: []string{"active"}},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVeryLongExpressionChains tests that parser handles long chains correctly
|
||||
func TestVeryLongExpressionChains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "five AND chain",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 2 AND priority < 6 AND points > 0",
|
||||
task: &task.Task{
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeBug,
|
||||
Priority: 4,
|
||||
Points: 3,
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "five OR chain - last matches",
|
||||
expr: "status = 'done' OR status = 'cancelled' OR status = 'inProgress' OR status = 'review' OR status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "alternating AND/OR chain",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3 OR points > 10 AND status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2, Points: 5},
|
||||
expect: true, // First OR condition matches
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComparisonOperators tests all comparison operators comprehensively
|
||||
func TestComparisonOperators(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// Equality
|
||||
{
|
||||
name: "equality with =",
|
||||
expr: "priority = 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "equality with ==",
|
||||
expr: "priority == 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
|
||||
// Inequality
|
||||
{
|
||||
name: "inequality - not equal",
|
||||
expr: "priority != 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "inequality - equal",
|
||||
expr: "priority != 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Greater than
|
||||
{
|
||||
name: "greater than - true",
|
||||
expr: "priority > 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "greater than - false equal",
|
||||
expr: "priority > 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "greater than - false less",
|
||||
expr: "priority > 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Less than
|
||||
{
|
||||
name: "less than - true",
|
||||
expr: "priority < 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "less than - false equal",
|
||||
expr: "priority < 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "less than - false greater",
|
||||
expr: "priority < 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Greater than or equal
|
||||
{
|
||||
name: "greater or equal - greater",
|
||||
expr: "priority >= 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "greater or equal - equal",
|
||||
expr: "priority >= 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "greater or equal - less",
|
||||
expr: "priority >= 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Less than or equal
|
||||
{
|
||||
name: "less or equal - less",
|
||||
expr: "priority <= 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "less or equal - equal",
|
||||
expr: "priority <= 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "less or equal - greater",
|
||||
expr: "priority <= 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// String comparisons (lexicographic)
|
||||
{
|
||||
name: "string greater than",
|
||||
expr: "status > 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true, // "ready" > "inProgress" lexicographically
|
||||
},
|
||||
{
|
||||
name: "string less than",
|
||||
expr: "status < 'ready'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: true, // "done" < "ready" lexicographically
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s (priority=%d, status=%s)",
|
||||
tt.expect, result, tt.expr, tt.task.Priority, tt.task.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealWorldScenarios tests realistic filter expressions users might write
|
||||
func TestRealWorldScenarios(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "recent high-priority bugs",
|
||||
expr: "type = 'bug' AND priority >= 4 AND NOW - CreatedAt < 7days",
|
||||
task: &task.Task{Type: task.TypeBug, Priority: 5, CreatedAt: now.Add(-3 * 24 * time.Hour)},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "stale tasks needing attention",
|
||||
expr: "status IN ['ready', 'inProgress', 'inProgress'] AND NOW - UpdatedAt > 14days",
|
||||
task: &task.Task{Status: task.StatusReady, UpdatedAt: now.Add(-20 * 24 * time.Hour)},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "UI/UX work in progress",
|
||||
expr: "tags IN ['ui', 'ux', 'design'] AND status IN ['ready', 'inProgress'] AND type != 'epic'",
|
||||
task: &task.Task{Tags: []string{"ui", "frontend"}, Status: task.StatusInProgress, Type: task.TypeStory},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "ready for release",
|
||||
expr: "status = 'done' AND type IN ['story', 'bug'] AND tags NOT IN ['not-deployable', 'experimental']",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Tags: []string{"ready"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "blocked high-value items",
|
||||
expr: "status = 'inProgress' AND (priority >= 4 OR points >= 8)",
|
||||
task: &task.Task{Status: task.StatusInProgress, Priority: 2, Points: 10},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, now, "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseFilter parses a filter expression string into an AST.
|
||||
// This is the main public entry point for filter parsing.
|
||||
//
|
||||
// Example expressions:
|
||||
// - status = 'done'
|
||||
// - type = 'bug' AND priority > 2
|
||||
// - status IN ['ready', 'in_progress']
|
||||
// - NOW - CreatedAt < 24hour
|
||||
// - (status = 'ready' OR status = 'in_progress') AND priority >= 3
|
||||
//
|
||||
// Returns nil expression for empty string (no filtering).
|
||||
func ParseFilter(expr string) (FilterExpr, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil, nil // empty filter = no filtering (all tasks pass)
|
||||
}
|
||||
|
||||
tokens, err := Tokenize(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parser := newFilterParser(tokens)
|
||||
result, err := parser.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure we consumed all tokens
|
||||
if parser.pos < len(parser.tokens) && parser.tokens[parser.pos].Type != TokenEOF {
|
||||
return nil, fmt.Errorf("unexpected token at position %d: %s", parser.pos, parser.tokens[parser.pos].Value)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestParseFilterWithIn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "tags IN with match",
|
||||
expr: "tags IN ['ui', 'charts']",
|
||||
task: &task.Task{Tags: []string{"ui", "backend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "tags IN with no match",
|
||||
expr: "tags IN ['frontend', 'api']",
|
||||
task: &task.Task{Tags: []string{"ui", "backend"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "tags IN with single value match",
|
||||
expr: "tags IN ['ui']",
|
||||
task: &task.Task{Tags: []string{"ui", "backend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "tags IN empty list",
|
||||
expr: "tags IN []",
|
||||
task: &task.Task{Tags: []string{"ui"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "status IN with match",
|
||||
expr: "status IN ['ready', 'inProgress']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "status IN with no match",
|
||||
expr: "status IN ['done', 'cancelled']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "status NOT IN with match",
|
||||
expr: "status NOT IN ['done', 'cancelled']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "status NOT IN with no match",
|
||||
expr: "status NOT IN ['ready', 'inProgress']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "type IN with match",
|
||||
expr: "type IN ['story', 'bug']",
|
||||
task: &task.Task{Type: task.TypeStory},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "priority IN with integers",
|
||||
expr: "priority IN [1, 2, 3]",
|
||||
task: &task.Task{Priority: 2},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "priority IN with no match",
|
||||
expr: "priority IN [1, 3, 5]",
|
||||
task: &task.Task{Priority: 2},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "combined with AND",
|
||||
expr: "tags IN ['ui', 'charts'] AND status = 'ready'",
|
||||
task: &task.Task{Tags: []string{"ui"}, Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "combined with AND, no match",
|
||||
expr: "tags IN ['ui', 'charts'] AND status = 'done'",
|
||||
task: &task.Task{Tags: []string{"ui"}, Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "combined with OR",
|
||||
expr: "tags IN ['ui'] OR tags IN ['backend']",
|
||||
task: &task.Task{Tags: []string{"backend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive tags",
|
||||
expr: "tags IN ['UI', 'Charts']",
|
||||
task: &task.Task{Tags: []string{"ui", "charts"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive status",
|
||||
expr: "status IN ['READY', 'IN_PROGRESS']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "snake_case filter matches camelCase task status",
|
||||
expr: "status = 'in_progress'",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "snake_case IN filter matches camelCase task status",
|
||||
expr: "status IN ['in_progress', 'review']",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "NOT with IN expression",
|
||||
expr: "NOT (tags IN ['deprecated', 'archived'])",
|
||||
task: &task.Task{Tags: []string{"ui", "active"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "complex expression",
|
||||
expr: "(tags IN ['ui', 'frontend'] OR type = 'bug') AND status NOT IN ['done']",
|
||||
task: &task.Task{Tags: []string{"ui"}, Type: task.TypeStory, Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenizeWithIn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []TokenType
|
||||
}{
|
||||
{
|
||||
name: "simple IN expression",
|
||||
input: "tags IN ['ui']",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
|
||||
TokenString, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "NOT IN expression",
|
||||
input: "status NOT IN ['done']",
|
||||
expected: []TokenType{TokenIdent, TokenNotIn, TokenLBracket,
|
||||
TokenString, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "multiple values with commas",
|
||||
input: "type IN ['bug', 'story', 'epic']",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
|
||||
TokenString, TokenComma, TokenString,
|
||||
TokenComma, TokenString, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "IN with numbers",
|
||||
input: "priority IN [1, 2, 3]",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
|
||||
TokenNumber, TokenComma, TokenNumber,
|
||||
TokenComma, TokenNumber, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
input: "tags IN []",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "IN with AND",
|
||||
input: "tags IN ['ui'] AND status = 'ready'",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket, TokenString, TokenRBracket,
|
||||
TokenAnd, TokenIdent, TokenOperator, TokenString, TokenEOF},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokens, err := Tokenize(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("tokenize failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tokens) != len(tt.expected) {
|
||||
t.Fatalf("Expected %d tokens, got %d", len(tt.expected), len(tokens))
|
||||
}
|
||||
|
||||
for i, tok := range tokens {
|
||||
if tok.Type != tt.expected[i] {
|
||||
t.Errorf("Token %d: expected type %d, got %d (value: %s)",
|
||||
i, tt.expected[i], tok.Type, tok.Value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFilterErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing opening bracket",
|
||||
expr: "tags IN 'ui', 'charts']",
|
||||
errMsg: "expected '['",
|
||||
},
|
||||
{
|
||||
name: "missing closing bracket",
|
||||
expr: "tags IN ['ui', 'charts'",
|
||||
errMsg: "expected ','",
|
||||
},
|
||||
{
|
||||
name: "missing comma",
|
||||
expr: "tags IN ['ui' 'charts']",
|
||||
errMsg: "expected ','",
|
||||
},
|
||||
{
|
||||
name: "invalid value in list",
|
||||
expr: "tags IN ['ui', =]",
|
||||
errMsg: "unexpected token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseFilter(tt.expr)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
// Could check error message contains expected substring if needed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInExprWithCurrentUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
currentUser string
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "assignee IN with CURRENT_USER match",
|
||||
expr: "assignee IN ['alice', CURRENT_USER, 'bob']",
|
||||
task: &task.Task{Assignee: "testuser"},
|
||||
currentUser: "testuser",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "assignee IN with CURRENT_USER no match",
|
||||
expr: "assignee IN ['alice', CURRENT_USER, 'bob']",
|
||||
task: &task.Task{Assignee: "charlie"},
|
||||
currentUser: "testuser",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "assignee IN with only CURRENT_USER",
|
||||
expr: "assignee IN [CURRENT_USER]",
|
||||
task: &task.Task{Assignee: "testuser"},
|
||||
currentUser: "testuser",
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), tt.currentUser)
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v", tt.expect, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInExprBackwardCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "old style tag comparison",
|
||||
expr: "tag = 'ui'",
|
||||
task: &task.Task{Tags: []string{"ui", "frontend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "old style OR chain",
|
||||
expr: "(tag = 'ui' OR tag = 'charts' OR tag = 'viz')",
|
||||
task: &task.Task{Tags: []string{"charts"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "status comparison",
|
||||
expr: "status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "complex old style expression",
|
||||
expr: "status = 'ready' AND (tag = 'ui' OR tag = 'backend')",
|
||||
task: &task.Task{Status: task.StatusReady, Tags: []string{"ui"}},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v", tt.expect, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TestOperatorPrecedence tests that operators follow correct precedence: NOT > AND > OR
|
||||
func TestOperatorPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// NOT has highest precedence
|
||||
{
|
||||
name: "NOT before AND",
|
||||
expr: "NOT status = 'done' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: true, // (NOT (status = 'done')) AND (type = 'bug') = true AND true = true
|
||||
},
|
||||
{
|
||||
name: "NOT before AND - false case",
|
||||
expr: "NOT status = 'done' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false, // (NOT (status = 'done')) AND (type = 'bug') = true AND false = false
|
||||
},
|
||||
{
|
||||
name: "NOT before AND - with done status",
|
||||
expr: "NOT status = 'done' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
|
||||
expect: false, // (NOT (status = 'done')) AND (type = 'bug') = false AND true = false
|
||||
},
|
||||
|
||||
// AND before OR - left side
|
||||
{
|
||||
name: "AND before OR - left match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "AND before OR - right match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "AND before OR - no match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory},
|
||||
expect: false, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = false OR false = false
|
||||
},
|
||||
|
||||
// AND before OR - right side
|
||||
{
|
||||
name: "AND before OR - right side left match",
|
||||
expr: "status = 'inProgress' AND type = 'bug' OR status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // (status = 'inProgress' AND type = 'bug') OR status = 'ready' = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "AND before OR - right side right match",
|
||||
expr: "status = 'inProgress' AND type = 'bug' OR status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (status = 'inProgress' AND type = 'bug') OR status = 'ready' = true OR false = true
|
||||
},
|
||||
|
||||
// Parentheses override precedence
|
||||
{
|
||||
name: "parentheses override AND/OR precedence - no match",
|
||||
expr: "(status = 'ready' OR status = 'inProgress') AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false, // (status = 'ready' OR status = 'inProgress') AND type = 'bug' = true AND false = false
|
||||
},
|
||||
{
|
||||
name: "parentheses override AND/OR precedence - match",
|
||||
expr: "(status = 'ready' OR status = 'inProgress') AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (status = 'ready' OR status = 'inProgress') AND type = 'bug' = true AND true = true
|
||||
},
|
||||
|
||||
// NOT with OR
|
||||
{
|
||||
name: "NOT before OR",
|
||||
expr: "NOT status = 'done' OR type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
|
||||
expect: false, // (NOT (status = 'done')) OR (type = 'bug') = false OR false = false
|
||||
},
|
||||
{
|
||||
name: "NOT before OR - match on NOT",
|
||||
expr: "NOT status = 'done' OR type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // (NOT (status = 'done')) OR (type = 'bug') = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "NOT before OR - match on type",
|
||||
expr: "NOT status = 'done' OR type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
|
||||
expect: true, // (NOT (status = 'done')) OR (type = 'bug') = false OR true = true
|
||||
},
|
||||
|
||||
// Complex precedence: NOT > AND > OR
|
||||
{
|
||||
name: "NOT > AND > OR - all operators",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5},
|
||||
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = true OR true = true
|
||||
},
|
||||
{
|
||||
name: "NOT > AND > OR - match on OR only",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
|
||||
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "NOT > AND > OR - match on AND only",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
|
||||
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "NOT > AND > OR - no match",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Priority: 1},
|
||||
expect: false, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = false OR false = false
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s\nTask: status=%s, type=%s, priority=%d",
|
||||
tt.expect, result, tt.expr, tt.task.Status, tt.task.Type, tt.task.Priority)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestedParentheses tests deeply nested parentheses expressions
|
||||
func TestNestedParentheses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// Double nesting
|
||||
{
|
||||
name: "double nested parentheses - match",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "double nested parentheses - no match on type",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "double nested parentheses - no match on status",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Triple nesting with NOT
|
||||
{
|
||||
name: "triple nested with NOT - match",
|
||||
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
|
||||
task: &task.Task{Status: task.StatusReady, Priority: 2},
|
||||
expect: true, // NOT ((false OR false) AND true) = NOT (false AND true) = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "triple nested with NOT - no match",
|
||||
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
|
||||
task: &task.Task{Status: task.StatusDone, Priority: 2},
|
||||
expect: false, // NOT ((true OR false) AND true) = NOT (true AND true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "triple nested with NOT - no match on priority",
|
||||
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
|
||||
task: &task.Task{Status: task.StatusDone, Priority: 5},
|
||||
expect: true, // NOT ((true OR false) AND false) = NOT (true AND false) = NOT false = true
|
||||
},
|
||||
|
||||
// Mixed nesting depth
|
||||
{
|
||||
name: "mixed nesting depth - OR at end",
|
||||
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (false AND (true OR false)) OR true = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "mixed nesting depth - match on nested OR",
|
||||
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: true, // (true AND (true OR false)) OR false = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "mixed nesting depth - match on final OR",
|
||||
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (false AND (true OR false)) OR true = false OR true = true
|
||||
},
|
||||
|
||||
// Complex nested with multiple operations
|
||||
{
|
||||
name: "complex nested - all conditions",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
|
||||
expect: true, // (true OR false) AND (false OR true) = true AND true = true
|
||||
},
|
||||
{
|
||||
name: "complex nested - left fails",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Priority: 5},
|
||||
expect: false, // (false OR false) AND (false OR true) = false AND true = false
|
||||
},
|
||||
{
|
||||
name: "complex nested - right fails",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2},
|
||||
expect: false, // (true OR false) AND (false OR false) = true AND false = false
|
||||
},
|
||||
|
||||
// Nested with NOT at different levels
|
||||
{
|
||||
name: "NOT outside nested expression",
|
||||
expr: "NOT ((status = 'done' AND type = 'bug') OR priority < 2)",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 3},
|
||||
expect: false, // NOT ((true AND true) OR false) = NOT (true OR false) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT inside nested expression",
|
||||
expr: "(NOT status = 'done' AND type = 'bug') OR priority > 5",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 3},
|
||||
expect: true, // ((NOT false) AND true) OR false = (true AND true) OR false = true
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplexBooleanChains tests multiple operators chained together
|
||||
func TestComplexBooleanChains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// Triple AND chain
|
||||
{
|
||||
name: "triple AND chain - all match",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple AND chain - first fails",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "triple AND chain - middle fails",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "triple AND chain - last fails",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Triple OR chain
|
||||
{
|
||||
name: "triple OR chain - first matches",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple OR chain - middle matches",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple OR chain - last matches",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple OR chain - none match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Mixed chain without parentheses - tests precedence
|
||||
{
|
||||
name: "mixed chain A OR B AND C - match on A",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2},
|
||||
expect: true, // status = 'ready' OR (type = 'bug' AND priority > 3) = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "mixed chain A OR B AND C - match on B AND C",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug, Priority: 5},
|
||||
expect: true, // status = 'ready' OR (type = 'bug' AND priority > 3) = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "mixed chain A OR B AND C - no match",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory, Priority: 2},
|
||||
expect: false, // status = 'ready' OR (type = 'bug' AND priority > 3) = false OR false = false
|
||||
},
|
||||
|
||||
// Longer chains
|
||||
{
|
||||
name: "four operator chain - AND heavy",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3 AND points < 10",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Points: 8},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "four operator chain - mixed AND/OR",
|
||||
expr: "status = 'ready' AND type = 'bug' OR status = 'inProgress' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory, Priority: 5},
|
||||
expect: true, // (status = 'ready' AND type = 'bug') OR (status = 'inProgress' AND priority > 3) = false OR true = true
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TestTimeExpressions tests time-based filter expressions like "NOW - CreatedAt < 24hour"
|
||||
func TestTimeExpressions(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// NOW - UpdatedAt comparisons
|
||||
{
|
||||
name: "recent task - under 2 hours",
|
||||
expr: "NOW - UpdatedAt < 2hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
expect: true, // Updated 1 hour ago, less than 2 hours
|
||||
},
|
||||
{
|
||||
name: "old task - over 2 hours",
|
||||
expr: "NOW - UpdatedAt < 2hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-3 * time.Hour)},
|
||||
expect: false, // Updated 3 hours ago, more than 2 hours
|
||||
},
|
||||
{
|
||||
name: "exact boundary - 2 hours",
|
||||
expr: "NOW - UpdatedAt < 2hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-2 * time.Hour)},
|
||||
expect: false, // Updated exactly 2 hours ago, not less than 2 hours
|
||||
},
|
||||
|
||||
// Greater than comparisons
|
||||
{
|
||||
name: "old task - over 1 week",
|
||||
expr: "NOW - UpdatedAt > 1week",
|
||||
task: &task.Task{UpdatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
expect: true, // Updated 10 days ago, more than 1 week
|
||||
},
|
||||
{
|
||||
name: "recent task - under 1 week",
|
||||
expr: "NOW - UpdatedAt > 1week",
|
||||
task: &task.Task{UpdatedAt: now.Add(-3 * 24 * time.Hour)},
|
||||
expect: false, // Updated 3 days ago, less than 1 week
|
||||
},
|
||||
|
||||
// NOW - CreatedAt comparisons
|
||||
{
|
||||
name: "recently created - under 1 month",
|
||||
expr: "NOW - CreatedAt < 1month",
|
||||
task: &task.Task{CreatedAt: now.Add(-15 * 24 * time.Hour)},
|
||||
expect: true, // Created 15 days ago, less than 30 days (1 month)
|
||||
},
|
||||
{
|
||||
name: "old creation - over 1 month",
|
||||
expr: "NOW - CreatedAt < 1month",
|
||||
task: &task.Task{CreatedAt: now.Add(-40 * 24 * time.Hour)},
|
||||
expect: false, // Created 40 days ago, more than 30 days
|
||||
},
|
||||
|
||||
// Less than or equal comparisons
|
||||
{
|
||||
name: "task age <= 24 hours - exact match",
|
||||
expr: "NOW - UpdatedAt <= 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-24 * time.Hour)},
|
||||
expect: true, // Updated exactly 24 hours ago
|
||||
},
|
||||
{
|
||||
name: "task age <= 24 hours - under",
|
||||
expr: "NOW - UpdatedAt <= 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour)},
|
||||
expect: true, // Updated 12 hours ago
|
||||
},
|
||||
{
|
||||
name: "task age <= 24 hours - over",
|
||||
expr: "NOW - UpdatedAt <= 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Hour)},
|
||||
expect: false, // Updated 30 hours ago
|
||||
},
|
||||
|
||||
// Greater than or equal comparisons
|
||||
{
|
||||
name: "task age >= 1 day - exact match",
|
||||
expr: "NOW - CreatedAt >= 1day",
|
||||
task: &task.Task{CreatedAt: now.Add(-24 * time.Hour)},
|
||||
expect: true, // Created exactly 1 day ago
|
||||
},
|
||||
{
|
||||
name: "task age >= 1 day - over",
|
||||
expr: "NOW - CreatedAt >= 1day",
|
||||
task: &task.Task{CreatedAt: now.Add(-48 * time.Hour)},
|
||||
expect: true, // Created 2 days ago
|
||||
},
|
||||
{
|
||||
name: "task age >= 1 day - under",
|
||||
expr: "NOW - CreatedAt >= 1day",
|
||||
task: &task.Task{CreatedAt: now.Add(-12 * time.Hour)},
|
||||
expect: false, // Created 12 hours ago
|
||||
},
|
||||
|
||||
// Different duration units
|
||||
{
|
||||
name: "minutes - under threshold",
|
||||
expr: "NOW - UpdatedAt < 60min",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Minute)},
|
||||
expect: true, // Updated 30 minutes ago
|
||||
},
|
||||
{
|
||||
name: "days - over threshold",
|
||||
expr: "NOW - UpdatedAt > 7days",
|
||||
task: &task.Task{UpdatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
expect: true, // Updated 10 days ago
|
||||
},
|
||||
{
|
||||
name: "weeks - under threshold",
|
||||
expr: "NOW - CreatedAt < 2weeks",
|
||||
task: &task.Task{CreatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
expect: true, // Created 10 days ago, less than 14 days
|
||||
},
|
||||
|
||||
// Combined with other conditions
|
||||
{
|
||||
name: "time condition AND status",
|
||||
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour), Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "time condition AND status - status mismatch",
|
||||
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour), Status: task.StatusDone},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "time condition AND status - time mismatch",
|
||||
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-48 * time.Hour), Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Time condition OR other conditions
|
||||
{
|
||||
name: "time condition OR type - time matches",
|
||||
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Minute), Type: task.TypeStory},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "time condition OR type - type matches",
|
||||
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Hour), Type: task.TypeBug},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "time condition OR type - neither matches",
|
||||
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Hour), Type: task.TypeStory},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// NOT with time conditions
|
||||
{
|
||||
name: "NOT time condition - should match",
|
||||
expr: "NOT (NOW - UpdatedAt < 24hours)",
|
||||
task: &task.Task{UpdatedAt: now.Add(-48 * time.Hour)},
|
||||
expect: true, // Updated 48 hours ago, NOT less than 24 hours
|
||||
},
|
||||
{
|
||||
name: "NOT time condition - should not match",
|
||||
expr: "NOT (NOW - UpdatedAt < 24hours)",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour)},
|
||||
expect: false, // Updated 12 hours ago, NOT (less than 24 hours) = false
|
||||
},
|
||||
|
||||
// Equality (rarely useful but should work)
|
||||
{
|
||||
name: "time equality - not equal",
|
||||
expr: "NOW - UpdatedAt = 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-25 * time.Hour)},
|
||||
expect: false, // Small timing differences make exact equality unlikely
|
||||
},
|
||||
|
||||
// Inequality
|
||||
{
|
||||
name: "time inequality - not equal",
|
||||
expr: "NOW - UpdatedAt != 0min",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Minute)},
|
||||
expect: true, // Updated 5 minutes ago, not equal to 0
|
||||
},
|
||||
|
||||
// Edge case: very recent update (near zero duration)
|
||||
{
|
||||
name: "very recent update",
|
||||
expr: "NOW - UpdatedAt < 1min",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Second)},
|
||||
expect: true, // Updated 5 seconds ago
|
||||
},
|
||||
|
||||
// seconds unit
|
||||
{
|
||||
name: "seconds - under threshold",
|
||||
expr: "NOW - UpdatedAt < 30secs",
|
||||
task: &task.Task{UpdatedAt: now.Add(-10 * time.Second)},
|
||||
expect: true, // Updated 10 seconds ago
|
||||
},
|
||||
{
|
||||
name: "seconds - over threshold",
|
||||
expr: "NOW - UpdatedAt < 10sec",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Second)},
|
||||
expect: false, // Updated 30 seconds ago
|
||||
},
|
||||
|
||||
// years unit
|
||||
{
|
||||
name: "years - under threshold",
|
||||
expr: "NOW - CreatedAt < 1year",
|
||||
task: &task.Task{CreatedAt: now.Add(-200 * 24 * time.Hour)},
|
||||
expect: true, // Created 200 days ago, less than 365 days
|
||||
},
|
||||
{
|
||||
name: "years - over threshold",
|
||||
expr: "NOW - CreatedAt > 1year",
|
||||
task: &task.Task{CreatedAt: now.Add(-400 * 24 * time.Hour)},
|
||||
expect: true, // Created 400 days ago, more than 365 days
|
||||
},
|
||||
|
||||
// Edge case: future time (shouldn't normally happen, but test negative duration)
|
||||
{
|
||||
name: "future time - negative duration",
|
||||
expr: "NOW - UpdatedAt < 1hour",
|
||||
task: &task.Task{UpdatedAt: now.Add(1 * time.Hour)},
|
||||
expect: true, // Future time results in negative duration, which is < 1hour
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, now, "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s\nTask UpdatedAt: %v, CreatedAt: %v",
|
||||
tt.expect, result, tt.expr, tt.task.UpdatedAt, tt.task.CreatedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeExpressionParsing tests that time expressions parse correctly
|
||||
func TestTimeExpressionParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid NOW - UpdatedAt",
|
||||
expr: "NOW - UpdatedAt < 24hours",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid NOW - CreatedAt",
|
||||
expr: "NOW - CreatedAt > 1week",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with minutes",
|
||||
expr: "NOW - UpdatedAt < 30min",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with days",
|
||||
expr: "NOW - CreatedAt >= 7days",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with months",
|
||||
expr: "NOW - UpdatedAt < 2months",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with seconds",
|
||||
expr: "NOW - UpdatedAt < 30secs",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with years",
|
||||
expr: "NOW - CreatedAt > 1year",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with parentheses",
|
||||
expr: "(NOW - UpdatedAt < 1hour)",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid combined with AND",
|
||||
expr: "NOW - UpdatedAt < 1day AND status = 'ready'",
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if tt.shouldError && err == nil {
|
||||
t.Error("Expected parsing error but got none")
|
||||
}
|
||||
if !tt.shouldError && err != nil {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
if !tt.shouldError && filter == nil {
|
||||
t.Error("Expected filter but got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleTimeConditions tests filters with multiple time-based conditions
|
||||
func TestMultipleTimeConditions(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "both time conditions true",
|
||||
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-12 * time.Hour),
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "first time condition false",
|
||||
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-3 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-12 * time.Hour),
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "second time condition false",
|
||||
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-48 * time.Hour),
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "time conditions with OR",
|
||||
expr: "NOW - UpdatedAt < 1hour OR NOW - CreatedAt < 1day",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-30 * time.Minute),
|
||||
},
|
||||
expect: true, // Updated recently
|
||||
},
|
||||
{
|
||||
name: "complex time expression with status",
|
||||
expr: "(NOW - UpdatedAt < 2hours OR NOW - CreatedAt < 1day) AND status = 'ready'",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-1 * time.Hour),
|
||||
Status: task.StatusReady,
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, now, "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_ParseError(t *testing.T) {
|
||||
_, err := ParseDuration("abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_UnknownUnit(t *testing.T) {
|
||||
_, err := ParseDuration("10xyz")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown duration unit")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/parsing"
|
||||
)
|
||||
|
||||
// TokenType represents the type of a lexer token
|
||||
type TokenType int
|
||||
|
||||
const (
|
||||
TokenEOF TokenType = iota
|
||||
TokenIdent // field names like status, type, NOW, CURRENT_USER
|
||||
TokenString // 'value' or "value"
|
||||
TokenNumber // integer literals
|
||||
TokenDuration // 24hour, 1week, etc.
|
||||
TokenOperator // =, ==, !=, >, <, >=, <=, +, -
|
||||
TokenAnd
|
||||
TokenOr
|
||||
TokenNot
|
||||
TokenIn // IN keyword
|
||||
TokenNotIn // NOT IN keyword combination
|
||||
TokenLParen
|
||||
TokenRParen
|
||||
TokenLBracket // [ for list literals
|
||||
TokenRBracket // ] for list literals
|
||||
TokenComma // , for list elements
|
||||
)
|
||||
|
||||
// Token represents a lexer token
|
||||
type Token struct {
|
||||
Type TokenType
|
||||
Value string
|
||||
}
|
||||
|
||||
// Multi-character operators mapped to their token types
|
||||
var multiCharOps = map[string]TokenType{
|
||||
"==": TokenOperator,
|
||||
"!=": TokenOperator,
|
||||
">=": TokenOperator,
|
||||
"<=": TokenOperator,
|
||||
}
|
||||
|
||||
// Keywords mapped to their token types
|
||||
var keywords = map[string]TokenType{
|
||||
"AND": TokenAnd,
|
||||
"OR": TokenOr,
|
||||
"NOT": TokenNot,
|
||||
"IN": TokenIn,
|
||||
}
|
||||
|
||||
// Time field names (uppercase)
|
||||
var timeFields = map[string]bool{
|
||||
"NOW": true,
|
||||
"CREATEDAT": true,
|
||||
"UPDATEDAT": true,
|
||||
"DUE": true,
|
||||
}
|
||||
|
||||
// isTimeField checks if a given identifier is a time field (case-insensitive)
|
||||
func isTimeField(name string) bool {
|
||||
return timeFields[strings.ToUpper(name)]
|
||||
}
|
||||
|
||||
// Tokenize breaks the expression into tokens
|
||||
func Tokenize(expr string) ([]Token, error) {
|
||||
var tokens []Token
|
||||
i := 0
|
||||
|
||||
for i < len(expr) {
|
||||
// Skip whitespace
|
||||
i = parsing.SkipWhitespace(expr, i)
|
||||
if i >= len(expr) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check for two-character operators first
|
||||
if i+1 < len(expr) {
|
||||
twoChar := expr[i : i+2]
|
||||
if tokType, ok := multiCharOps[twoChar]; ok {
|
||||
tokens = append(tokens, Token{Type: tokType, Value: twoChar})
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Single character tokens
|
||||
switch expr[i] {
|
||||
case '(':
|
||||
tokens = append(tokens, Token{Type: TokenLParen, Value: "("})
|
||||
i++
|
||||
continue
|
||||
case ')':
|
||||
tokens = append(tokens, Token{Type: TokenRParen, Value: ")"})
|
||||
i++
|
||||
continue
|
||||
case '[':
|
||||
tokens = append(tokens, Token{Type: TokenLBracket, Value: "["})
|
||||
i++
|
||||
continue
|
||||
case ']':
|
||||
tokens = append(tokens, Token{Type: TokenRBracket, Value: "]"})
|
||||
i++
|
||||
continue
|
||||
case ',':
|
||||
tokens = append(tokens, Token{Type: TokenComma, Value: ","})
|
||||
i++
|
||||
continue
|
||||
case '=', '>', '<', '+', '-':
|
||||
tokens = append(tokens, Token{Type: TokenOperator, Value: string(expr[i])})
|
||||
i++
|
||||
continue
|
||||
case '\'', '"':
|
||||
// String literal
|
||||
quote := expr[i]
|
||||
i++
|
||||
start := i
|
||||
for i < len(expr) && expr[i] != quote {
|
||||
i++
|
||||
}
|
||||
if i >= len(expr) {
|
||||
return nil, fmt.Errorf("unterminated string literal")
|
||||
}
|
||||
tokens = append(tokens, Token{Type: TokenString, Value: expr[start:i]})
|
||||
i++ // skip closing quote
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for identifiers, keywords, numbers, and durations
|
||||
if unicode.IsLetter(rune(expr[i])) || expr[i] == '_' {
|
||||
word, newPos := parsing.ReadWhile(expr, i, func(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||
})
|
||||
i = newPos
|
||||
wordUpper := strings.ToUpper(word)
|
||||
|
||||
// Check for "NOT IN" keyword combination
|
||||
if wordUpper == "NOT" {
|
||||
if matched, endPos := parsing.PeekKeyword(expr, i, "IN"); matched {
|
||||
i = endPos
|
||||
tokens = append(tokens, Token{Type: TokenNotIn, Value: "NOT IN"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a keyword
|
||||
if tokType, ok := keywords[wordUpper]; ok {
|
||||
tokens = append(tokens, Token{Type: tokType, Value: word})
|
||||
} else {
|
||||
tokens = append(tokens, Token{Type: TokenIdent, Value: word})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for numbers (which might be followed by duration unit)
|
||||
if unicode.IsDigit(rune(expr[i])) {
|
||||
numStr, newPos := parsing.ReadWhile(expr, i, unicode.IsDigit)
|
||||
i = newPos
|
||||
|
||||
// Check if followed by duration unit
|
||||
if i < len(expr) && unicode.IsLetter(rune(expr[i])) {
|
||||
unitStr, unitEnd := parsing.ReadWhile(expr, i, unicode.IsLetter)
|
||||
fullWord := numStr + unitStr
|
||||
// Check if it's a valid duration
|
||||
if IsDurationLiteral(fullWord) {
|
||||
tokens = append(tokens, Token{Type: TokenDuration, Value: fullWord})
|
||||
i = unitEnd
|
||||
} else {
|
||||
// Not a valid duration, just a number
|
||||
tokens = append(tokens, Token{Type: TokenNumber, Value: numStr})
|
||||
}
|
||||
} else {
|
||||
tokens = append(tokens, Token{Type: TokenNumber, Value: numStr})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unexpected character at position %d: %c", i, expr[i])
|
||||
}
|
||||
|
||||
tokens = append(tokens, Token{Type: TokenEOF, Value: ""})
|
||||
return tokens, nil
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
package filter
|
||||
|
||||
import "fmt"
|
||||
|
||||
// filterParser is a recursive descent parser for filter expressions
|
||||
type filterParser struct {
|
||||
tokens []Token
|
||||
pos int
|
||||
}
|
||||
|
||||
// newFilterParser creates a new parser with the given tokens
|
||||
func newFilterParser(tokens []Token) *filterParser {
|
||||
return &filterParser{tokens: tokens, pos: 0}
|
||||
}
|
||||
|
||||
func (p *filterParser) current() Token {
|
||||
if p.pos >= len(p.tokens) {
|
||||
return Token{Type: TokenEOF}
|
||||
}
|
||||
return p.tokens[p.pos]
|
||||
}
|
||||
|
||||
func (p *filterParser) advance() {
|
||||
p.pos++
|
||||
}
|
||||
|
||||
func (p *filterParser) expect(t TokenType) error {
|
||||
tok := p.current()
|
||||
if tok.Type != t {
|
||||
return fmt.Errorf("expected token type %d, got %d (%s)", t, tok.Type, tok.Value)
|
||||
}
|
||||
p.advance()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLeftAssociativeBinary parses left-associative binary operations
|
||||
// like "a AND b AND c" -> ((a AND b) AND c)
|
||||
func (p *filterParser) parseLeftAssociativeBinary(
|
||||
operatorType TokenType,
|
||||
operatorStr string,
|
||||
subExprParser func() (FilterExpr, error),
|
||||
) (FilterExpr, error) {
|
||||
left, err := subExprParser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for p.current().Type == operatorType {
|
||||
p.advance()
|
||||
right, err := subExprParser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &BinaryExpr{Op: operatorStr, Left: left, Right: right}
|
||||
}
|
||||
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// parseExpr parses: expr = orExpr
|
||||
func (p *filterParser) parseExpr() (FilterExpr, error) {
|
||||
return p.parseOrExpr()
|
||||
}
|
||||
|
||||
// parseOrExpr parses: orExpr = andExpr (OR andExpr)*
|
||||
func (p *filterParser) parseOrExpr() (FilterExpr, error) {
|
||||
return p.parseLeftAssociativeBinary(TokenOr, "OR", p.parseAndExpr)
|
||||
}
|
||||
|
||||
// parseAndExpr parses: andExpr = notExpr (AND notExpr)*
|
||||
func (p *filterParser) parseAndExpr() (FilterExpr, error) {
|
||||
return p.parseLeftAssociativeBinary(TokenAnd, "AND", p.parseNotExpr)
|
||||
}
|
||||
|
||||
// parseNotExpr parses: notExpr = NOT notExpr | primaryExpr
|
||||
func (p *filterParser) parseNotExpr() (FilterExpr, error) {
|
||||
if p.current().Type == TokenNot {
|
||||
p.advance()
|
||||
expr, err := p.parseNotExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UnaryExpr{Op: "NOT", Expr: expr}, nil
|
||||
}
|
||||
return p.parsePrimaryExpr()
|
||||
}
|
||||
|
||||
// parsePrimaryExpr parses: primaryExpr = '(' expr ')' | inExpr | comparison
|
||||
func (p *filterParser) parsePrimaryExpr() (FilterExpr, error) {
|
||||
if p.current().Type == TokenLParen {
|
||||
p.advance()
|
||||
expr, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.expect(TokenRParen); err != nil {
|
||||
return nil, fmt.Errorf("expected closing parenthesis: %w", err)
|
||||
}
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
// Try to parse as IN expression or regular comparison
|
||||
// We need to look ahead to distinguish:
|
||||
// field IN [...] -> InExpr
|
||||
// field NOT IN [...] -> InExpr
|
||||
// field = value -> CompareExpr
|
||||
|
||||
// Check if this starts with an identifier (field name)
|
||||
if p.current().Type == TokenIdent {
|
||||
fieldName := p.current().Value
|
||||
p.advance()
|
||||
|
||||
// Check next token for IN or NOT IN
|
||||
nextTok := p.current()
|
||||
|
||||
if nextTok.Type == TokenIn {
|
||||
p.advance()
|
||||
return p.parseInExpr(fieldName, false)
|
||||
}
|
||||
if nextTok.Type == TokenNotIn {
|
||||
p.advance()
|
||||
return p.parseInExpr(fieldName, true)
|
||||
}
|
||||
|
||||
// Otherwise, backtrack and parse as regular comparison
|
||||
p.pos--
|
||||
return p.parseComparison()
|
||||
}
|
||||
|
||||
// Not an identifier, try parsing as comparison
|
||||
return p.parseComparison()
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package filter
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package filter
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValidateFilterStatuses walks a FilterExpr AST and checks that any status
|
||||
// references (in CompareExpr and InExpr nodes where Field == "status") are
|
||||
// valid according to the provided validator function.
|
||||
// Returns the first invalid status found with a descriptive error, or nil.
|
||||
func ValidateFilterStatuses(expr FilterExpr, validStatus func(string) bool) error {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch e := expr.(type) {
|
||||
case *BinaryExpr:
|
||||
if err := ValidateFilterStatuses(e.Left, validStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
return ValidateFilterStatuses(e.Right, validStatus)
|
||||
|
||||
case *UnaryExpr:
|
||||
return ValidateFilterStatuses(e.Expr, validStatus)
|
||||
|
||||
case *CompareExpr:
|
||||
if e.Field != "status" {
|
||||
return nil
|
||||
}
|
||||
if strVal, ok := e.Value.(string); ok {
|
||||
if !validStatus(strVal) {
|
||||
return fmt.Errorf("filter references unknown status %q", strVal)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case *InExpr:
|
||||
if e.Field != "status" {
|
||||
return nil
|
||||
}
|
||||
for _, val := range e.Values {
|
||||
if strVal, ok := val.(string); ok {
|
||||
if !validStatus(strVal) {
|
||||
return fmt.Errorf("filter references unknown status %q", strVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validStatus(key string) bool {
|
||||
valid := map[string]bool{
|
||||
"backlog": true, "ready": true, "inProgress": true, "review": true, "done": true,
|
||||
}
|
||||
return valid[key]
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_ValidCompare(t *testing.T) {
|
||||
expr := &CompareExpr{Field: "status", Op: "=", Value: "ready"}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_InvalidCompare(t *testing.T) {
|
||||
expr := &CompareExpr{Field: "status", Op: "=", Value: "bogus"}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status")
|
||||
}
|
||||
if got := err.Error(); got != `filter references unknown status "bogus"` {
|
||||
t.Errorf("unexpected error message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_ValidInExpr(t *testing.T) {
|
||||
expr := &InExpr{Field: "status", Values: []interface{}{"ready", "done"}}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_InvalidInExpr(t *testing.T) {
|
||||
expr := &InExpr{Field: "status", Values: []interface{}{"ready", "unknown"}}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status in IN list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_NonStatusField(t *testing.T) {
|
||||
expr := &CompareExpr{Field: "type", Op: "=", Value: "anything"}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error for non-status field, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_BinaryExpr(t *testing.T) {
|
||||
expr := &BinaryExpr{
|
||||
Op: "AND",
|
||||
Left: &CompareExpr{Field: "status", Op: "=", Value: "ready"},
|
||||
Right: &CompareExpr{Field: "status", Op: "!=", Value: "bogus"},
|
||||
}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status in right branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_UnaryExpr(t *testing.T) {
|
||||
expr := &UnaryExpr{
|
||||
Op: "NOT",
|
||||
Expr: &CompareExpr{Field: "status", Op: "=", Value: "invalid"},
|
||||
}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status in NOT expr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_Nil(t *testing.T) {
|
||||
if err := ValidateFilterStatuses(nil, validStatus); err != nil {
|
||||
t.Errorf("expected no error for nil expr, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_IntValue(t *testing.T) {
|
||||
// Status compared with int value — should be ignored (only string values checked)
|
||||
expr := &CompareExpr{Field: "status", Op: "=", Value: 42}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error for non-string value, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,20 @@ package plugin
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestPluginWithInFilter(t *testing.T) {
|
||||
// Test loading a plugin definition with IN filter
|
||||
func newTestExecutor() *ruki.Executor {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
return ruki.NewExecutor(schema, func() string { return "testuser" },
|
||||
ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimePlugin})
|
||||
}
|
||||
|
||||
func TestPluginWithTagFilter(t *testing.T) {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pluginYAML := `
|
||||
name: UI Tasks
|
||||
foreground: "#ffffff"
|
||||
|
|
@ -16,136 +23,119 @@ background: "#0000ff"
|
|||
key: U
|
||||
lanes:
|
||||
- name: UI
|
||||
filter: tags IN ['ui', 'ux', 'design']
|
||||
filter: select where "ui" in tags or "ux" in tags or "design" in tags
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plugin: %v", err)
|
||||
t.Fatalf("failed to parse plugin: %v", err)
|
||||
}
|
||||
|
||||
if def.GetName() != "UI Tasks" {
|
||||
t.Errorf("Expected name 'UI Tasks', got '%s'", def.GetName())
|
||||
t.Errorf("expected name 'UI Tasks', got '%s'", def.GetName())
|
||||
}
|
||||
|
||||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
t.Fatalf("expected TikiPlugin, got %T", def)
|
||||
}
|
||||
|
||||
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
|
||||
t.Fatal("Expected lane filter to be parsed")
|
||||
t.Fatal("expected lane filter to be parsed")
|
||||
}
|
||||
|
||||
// Test filter evaluation with matching tasks
|
||||
matchingTask := &task.Task{
|
||||
ID: "TIKI-1",
|
||||
Title: "Design mockups",
|
||||
Tags: []string{"ui", "design"},
|
||||
Status: task.StatusReady,
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Title: "Design mockups", Tags: []string{"ui", "design"}, Status: task.StatusReady},
|
||||
{ID: "TIKI-000002", Title: "Backend API", Tags: []string{"backend", "api"}, Status: task.StatusReady},
|
||||
{ID: "TIKI-000003", Title: "UX Research", Tags: []string{"ux", "research"}, Status: task.StatusReady},
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match task with 'ui' and 'design' tags")
|
||||
executor := newTestExecutor()
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("executor error: %v", err)
|
||||
}
|
||||
|
||||
// Test filter evaluation with non-matching tasks
|
||||
nonMatchingTask := &task.Task{
|
||||
ID: "TIKI-2",
|
||||
Title: "Backend API",
|
||||
Tags: []string{"backend", "api"},
|
||||
Status: task.StatusReady,
|
||||
filtered := result.Select.Tasks
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("expected 2 matching tasks, got %d", len(filtered))
|
||||
}
|
||||
|
||||
if tp.Lanes[0].Filter.Evaluate(nonMatchingTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to NOT match task with 'backend' and 'api' tags")
|
||||
// task with "ui"+"design" and task with "ux" should match; "backend"+"api" should not
|
||||
ids := map[string]bool{}
|
||||
for _, tk := range filtered {
|
||||
ids[tk.ID] = true
|
||||
}
|
||||
|
||||
// Test with task that has one matching tag
|
||||
partialMatchTask := &task.Task{
|
||||
ID: "TIKI-3",
|
||||
Title: "UX Research",
|
||||
Tags: []string{"ux", "research"},
|
||||
Status: task.StatusReady,
|
||||
if !ids["TIKI-000001"] {
|
||||
t.Error("expected task TIKI-000001 (ui, design tags) to match")
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(partialMatchTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match task with 'ux' tag")
|
||||
if ids["TIKI-000002"] {
|
||||
t.Error("expected task TIKI-000002 (backend, api tags) to NOT match")
|
||||
}
|
||||
if !ids["TIKI-000003"] {
|
||||
t.Error("expected task TIKI-000003 (ux tag) to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWithComplexInFilter(t *testing.T) {
|
||||
// Test plugin with combined filters
|
||||
func TestPluginWithComplexTagAndStatusFilter(t *testing.T) {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pluginYAML := `
|
||||
name: Active Work
|
||||
key: A
|
||||
lanes:
|
||||
- name: Active
|
||||
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'backlog']
|
||||
filter: select where ("ui" in tags or "backend" in tags) and status != "done" and status != "backlog"
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plugin: %v", err)
|
||||
t.Fatalf("failed to parse plugin: %v", err)
|
||||
}
|
||||
|
||||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
t.Fatalf("expected TikiPlugin, got %T", def)
|
||||
}
|
||||
|
||||
// Should match: has 'ui' tag and status is 'ready' (not done)
|
||||
matchingTask := &task.Task{
|
||||
ID: "TIKI-1",
|
||||
Tags: []string{"ui", "frontend"},
|
||||
Status: task.StatusReady,
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Tags: []string{"ui", "frontend"}, Status: task.StatusReady},
|
||||
{ID: "TIKI-000002", Tags: []string{"ui"}, Status: task.StatusDone},
|
||||
{ID: "TIKI-000003", Tags: []string{"docs", "testing"}, Status: task.StatusInProgress},
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match active UI task")
|
||||
executor := newTestExecutor()
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("executor error: %v", err)
|
||||
}
|
||||
|
||||
// Should NOT match: has 'ui' tag but status is 'done'
|
||||
doneTask := &task.Task{
|
||||
ID: "TIKI-2",
|
||||
Tags: []string{"ui"},
|
||||
Status: task.StatusDone,
|
||||
filtered := result.Select.Tasks
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("expected 1 matching task, got %d", len(filtered))
|
||||
}
|
||||
|
||||
if tp.Lanes[0].Filter.Evaluate(doneTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to NOT match done UI task")
|
||||
}
|
||||
|
||||
// Should NOT match: status is active but no matching tags
|
||||
noTagsTask := &task.Task{
|
||||
ID: "TIKI-3",
|
||||
Tags: []string{"docs", "testing"},
|
||||
Status: task.StatusInProgress,
|
||||
}
|
||||
|
||||
if tp.Lanes[0].Filter.Evaluate(noTagsTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to NOT match task without matching tags")
|
||||
if filtered[0].ID != "TIKI-000001" {
|
||||
t.Errorf("expected TIKI-000001, got %s", filtered[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWithStatusInFilter(t *testing.T) {
|
||||
// Test plugin filtering by status
|
||||
func TestPluginWithStatusFilter(t *testing.T) {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pluginYAML := `
|
||||
name: In Progress Work
|
||||
key: W
|
||||
lanes:
|
||||
- name: Active
|
||||
filter: status IN ['ready', 'inProgress', 'inProgress']
|
||||
filter: select where status = "ready" or status = "inProgress"
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plugin: %v", err)
|
||||
t.Fatalf("failed to parse plugin: %v", err)
|
||||
}
|
||||
|
||||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
t.Fatalf("expected TikiPlugin, got %T", def)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
|
|
@ -153,23 +143,27 @@ lanes:
|
|||
status task.Status
|
||||
expect bool
|
||||
}{
|
||||
{"todo status", task.StatusReady, true},
|
||||
{"ready status", task.StatusReady, true},
|
||||
{"inProgress status", task.StatusInProgress, true},
|
||||
{"blocked status", task.StatusInProgress, true},
|
||||
{"done status", task.StatusDone, false},
|
||||
{"review status", task.StatusReview, false},
|
||||
}
|
||||
|
||||
executor := newTestExecutor()
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task := &task.Task{
|
||||
ID: "TIKI-1",
|
||||
Status: tc.status,
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Status: tc.status},
|
||||
}
|
||||
|
||||
result := tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser")
|
||||
if result != tc.expect {
|
||||
t.Errorf("Expected %v for status %s, got %v", tc.expect, tc.status, result)
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("executor error: %v", err)
|
||||
}
|
||||
|
||||
got := len(result.Select.Tasks) > 0
|
||||
if got != tc.expect {
|
||||
t.Errorf("expected match=%v for status %s, got %v", tc.expect, tc.status, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ type WorkflowFile struct {
|
|||
|
||||
// loadPluginsFromFile loads plugins from a single workflow.yaml file.
|
||||
// Returns the successfully loaded plugins and any validation errors encountered.
|
||||
func loadPluginsFromFile(path string) ([]Plugin, []string) {
|
||||
func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
slog.Warn("failed to read workflow.yaml", "path", path, "error", err)
|
||||
|
|
@ -45,7 +46,7 @@ func loadPluginsFromFile(path string) ([]Plugin, []string) {
|
|||
}
|
||||
|
||||
source := fmt.Sprintf("%s:%s", path, cfg.Name)
|
||||
p, err := parsePluginConfig(cfg, source)
|
||||
p, err := parsePluginConfig(cfg, source, schema)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("%s: view %q: %v", path, cfg.Name, err)
|
||||
slog.Warn("failed to load plugin from workflow.yaml", "name", cfg.Name, "error", err)
|
||||
|
|
@ -107,7 +108,7 @@ func mergePluginLists(base, overrides []Plugin) []Plugin {
|
|||
// Files are discovered via config.FindWorkflowFiles() which returns user config first, then project config.
|
||||
// Plugins from later files override same-named plugins from earlier files via field merging.
|
||||
// Returns an error when workflow files were found but no valid plugins could be loaded.
|
||||
func LoadPlugins() ([]Plugin, error) {
|
||||
func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
|
||||
files := config.FindWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
slog.Debug("no workflow.yaml files found")
|
||||
|
|
@ -117,12 +118,12 @@ func LoadPlugins() ([]Plugin, error) {
|
|||
var allErrors []string
|
||||
|
||||
// First file is the base (typically user config)
|
||||
base, errs := loadPluginsFromFile(files[0])
|
||||
base, errs := loadPluginsFromFile(files[0], schema)
|
||||
allErrors = append(allErrors, errs...)
|
||||
|
||||
// Remaining files are overrides, merged in order
|
||||
for _, path := range files[1:] {
|
||||
overrides, errs := loadPluginsFromFile(path)
|
||||
overrides, errs := loadPluginsFromFile(path, schema)
|
||||
allErrors = append(allErrors, errs...)
|
||||
if len(overrides) > 0 {
|
||||
base = mergePluginLists(base, overrides)
|
||||
|
|
|
|||
|
|
@ -4,26 +4,24 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestParsePluginConfig_FullyInline(t *testing.T) {
|
||||
schema := testSchema()
|
||||
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Inline Test",
|
||||
Foreground: "#ffffff",
|
||||
Background: "#000000",
|
||||
Key: "I",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: "status = 'ready'"},
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
Sort: "Priority DESC",
|
||||
View: "expanded",
|
||||
Default: true,
|
||||
}
|
||||
|
||||
def, err := parsePluginConfig(cfg, "test")
|
||||
def, err := parsePluginConfig(cfg, "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -49,34 +47,24 @@ func TestParsePluginConfig_FullyInline(t *testing.T) {
|
|||
t.Fatal("Expected lane filter to be parsed")
|
||||
}
|
||||
|
||||
if len(tp.Sort) != 1 || tp.Sort[0].Field != "priority" || !tp.Sort[0].Descending {
|
||||
t.Errorf("Expected sort 'Priority DESC', got %+v", tp.Sort)
|
||||
if !tp.Lanes[0].Filter.IsSelect() {
|
||||
t.Error("Expected lane filter to be a SELECT statement")
|
||||
}
|
||||
|
||||
if !tp.IsDefault() {
|
||||
t.Error("Expected IsDefault() to return true")
|
||||
}
|
||||
|
||||
// test filter evaluation
|
||||
task := &taskpkg.Task{
|
||||
ID: "TIKI-1",
|
||||
Status: taskpkg.StatusReady,
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match todo task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_Minimal(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Minimal",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bugs", Filter: "type = 'bug'"},
|
||||
{Name: "Bugs", Filter: `select where type = "bug"`},
|
||||
},
|
||||
}
|
||||
|
||||
def, err := parsePluginConfig(cfg, "test")
|
||||
def, err := parsePluginConfig(cfg, "test", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -98,11 +86,11 @@ func TestParsePluginConfig_Minimal(t *testing.T) {
|
|||
func TestParsePluginConfig_NoName(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: "status = 'ready'"},
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test")
|
||||
_, err := parsePluginConfig(cfg, "test", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for plugin without name")
|
||||
}
|
||||
|
|
@ -117,7 +105,7 @@ func TestPluginTypeExplicit(t *testing.T) {
|
|||
Text: "some text",
|
||||
}
|
||||
|
||||
def, err := parsePluginConfig(cfg, "test")
|
||||
def, err := parsePluginConfig(cfg, "test", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -140,8 +128,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
|
|||
key: "F5"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready'
|
||||
sort: Priority
|
||||
filter: select where status = "ready"
|
||||
- name: TestDocs
|
||||
type: doki
|
||||
fetcher: internal
|
||||
|
|
@ -153,7 +140,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
|
|||
t.Fatalf("Failed to write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
plugins, errs := loadPluginsFromFile(workflowPath)
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("Expected no errors, got: %v", errs)
|
||||
}
|
||||
|
|
@ -187,7 +174,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
|
|||
|
||||
func TestLoadPluginsFromFile_NoFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"))
|
||||
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"), testSchema())
|
||||
if plugins != nil {
|
||||
t.Errorf("Expected nil plugins when no workflow.yaml, got %d", len(plugins))
|
||||
}
|
||||
|
|
@ -203,7 +190,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
|
|||
key: "V"
|
||||
lanes:
|
||||
- name: Todo
|
||||
filter: status = 'ready'
|
||||
filter: select where status = "ready"
|
||||
- name: Invalid
|
||||
type: unknown
|
||||
`
|
||||
|
|
@ -213,7 +200,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
|
|||
}
|
||||
|
||||
// should load valid plugin and skip invalid one
|
||||
plugins, errs := loadPluginsFromFile(workflowPath)
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("Expected 1 valid plugin (invalid skipped), got %d", len(plugins))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ type pluginFileConfig struct {
|
|||
Background string `yaml:"background"`
|
||||
Key string `yaml:"key"` // single character
|
||||
Filter string `yaml:"filter"`
|
||||
Sort string `yaml:"sort"`
|
||||
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
|
||||
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
|
||||
Fetcher string `yaml:"fetcher"`
|
||||
|
|
@ -46,7 +45,6 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
|
|||
Default: baseTiki.Default,
|
||||
},
|
||||
Lanes: baseTiki.Lanes,
|
||||
Sort: baseTiki.Sort,
|
||||
ViewMode: baseTiki.ViewMode,
|
||||
Actions: baseTiki.Actions,
|
||||
}
|
||||
|
|
@ -69,9 +67,6 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
|
|||
if len(overrideTiki.Lanes) > 0 {
|
||||
result.Lanes = overrideTiki.Lanes
|
||||
}
|
||||
if overrideTiki.Sort != nil {
|
||||
result.Sort = overrideTiki.Sort
|
||||
}
|
||||
if overrideTiki.ViewMode != "" {
|
||||
result.ViewMode = overrideTiki.ViewMode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,24 @@ import (
|
|||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// mustParseFilter is a test helper that parses a ruki select statement or panics.
|
||||
func mustParseFilter(t *testing.T, expr string) *ruki.ValidatedStatement {
|
||||
t.Helper()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
stmt, err := parser.ParseAndValidateStatement(expr, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse ruki statement %q: %v", expr, err)
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
|
||||
func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
|
||||
baseFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
baseSort, _ := ParseSort("Priority")
|
||||
baseFilter := mustParseFilter(t, `select where status = "ready"`)
|
||||
|
||||
base := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
|
|
@ -25,11 +37,10 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
|
|||
Lanes: []TikiLane{
|
||||
{Name: "Todo", Columns: 1, Filter: baseFilter},
|
||||
},
|
||||
Sort: baseSort,
|
||||
ViewMode: "compact",
|
||||
}
|
||||
|
||||
overrideFilter, _ := filter.ParseFilter("type = 'bug'")
|
||||
overrideFilter := mustParseFilter(t, `select where type = "bug"`)
|
||||
override := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
Name: "Base",
|
||||
|
|
@ -45,49 +56,42 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
|
|||
Lanes: []TikiLane{
|
||||
{Name: "Bugs", Columns: 1, Filter: overrideFilter},
|
||||
},
|
||||
Sort: nil,
|
||||
ViewMode: "expanded",
|
||||
}
|
||||
|
||||
result := mergePluginDefinitions(base, override)
|
||||
resultTiki, ok := result.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatal("Expected result to be *TikiPlugin")
|
||||
t.Fatal("expected result to be *TikiPlugin")
|
||||
}
|
||||
|
||||
// Check overridden values
|
||||
// check overridden values
|
||||
if resultTiki.Rune != 'O' {
|
||||
t.Errorf("Expected rune 'O', got %q", resultTiki.Rune)
|
||||
t.Errorf("expected rune 'O', got %q", resultTiki.Rune)
|
||||
}
|
||||
if resultTiki.Modifier != tcell.ModAlt {
|
||||
t.Errorf("Expected ModAlt, got %v", resultTiki.Modifier)
|
||||
t.Errorf("expected ModAlt, got %v", resultTiki.Modifier)
|
||||
}
|
||||
if resultTiki.Foreground != tcell.ColorGreen {
|
||||
t.Errorf("Expected green foreground, got %v", resultTiki.Foreground)
|
||||
t.Errorf("expected green foreground, got %v", resultTiki.Foreground)
|
||||
}
|
||||
if resultTiki.ViewMode != "expanded" {
|
||||
t.Errorf("Expected expanded view, got %q", resultTiki.ViewMode)
|
||||
t.Errorf("expected expanded view, got %q", resultTiki.ViewMode)
|
||||
}
|
||||
if len(resultTiki.Lanes) != 1 || resultTiki.Lanes[0].Filter == nil {
|
||||
t.Error("Expected lane filter to be overridden")
|
||||
}
|
||||
|
||||
// Check that base sort is kept when override has nil
|
||||
if resultTiki.Sort == nil {
|
||||
t.Error("Expected base sort to be retained")
|
||||
t.Error("expected lane filter to be overridden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
|
||||
// This test verifies the bug fix where Modifier was not being copied from base
|
||||
baseFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
baseFilter := mustParseFilter(t, `select where status = "ready"`)
|
||||
|
||||
base := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
Name: "Base",
|
||||
Key: tcell.KeyRune,
|
||||
Rune: 'M',
|
||||
Modifier: tcell.ModAlt, // This should be preserved
|
||||
Modifier: tcell.ModAlt, // this should be preserved
|
||||
Foreground: tcell.ColorWhite,
|
||||
Background: tcell.ColorDefault,
|
||||
Type: "tiki",
|
||||
|
|
@ -97,7 +101,7 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
// Override with no modifier change (Modifier: 0)
|
||||
// override with no modifier change (Modifier: 0)
|
||||
override := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
Name: "Base",
|
||||
|
|
@ -110,14 +114,14 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
|
|||
result := mergePluginDefinitions(base, override)
|
||||
resultTiki, ok := result.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatal("Expected result to be *TikiPlugin")
|
||||
t.Fatal("expected result to be *TikiPlugin")
|
||||
}
|
||||
|
||||
// The Modifier from base should be preserved
|
||||
// the Modifier from base should be preserved
|
||||
if resultTiki.Modifier != tcell.ModAlt {
|
||||
t.Errorf("Expected ModAlt to be preserved from base, got %v", resultTiki.Modifier)
|
||||
t.Errorf("expected ModAlt to be preserved from base, got %v", resultTiki.Modifier)
|
||||
}
|
||||
if resultTiki.Rune != 'M' {
|
||||
t.Errorf("Expected rune 'M' to be preserved from base, got %q", resultTiki.Rune)
|
||||
t.Errorf("expected rune 'M' to be preserved from base, got %q", resultTiki.Rune)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ import (
|
|||
"github.com/gdamore/tcell/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// parsePluginConfig parses a pluginFileConfig into a Plugin
|
||||
func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
||||
func parsePluginConfig(cfg pluginFileConfig, source string, schema ruki.Schema) (Plugin, error) {
|
||||
if cfg.Name == "" {
|
||||
return nil, fmt.Errorf("plugin must have a name (%s)", source)
|
||||
}
|
||||
|
|
@ -54,9 +53,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
if cfg.Filter != "" {
|
||||
return nil, fmt.Errorf("doki plugin cannot have 'filter'")
|
||||
}
|
||||
if cfg.Sort != "" {
|
||||
return nil, fmt.Errorf("doki plugin cannot have 'sort'")
|
||||
}
|
||||
if cfg.View != "" {
|
||||
return nil, fmt.Errorf("doki plugin cannot have 'view'")
|
||||
}
|
||||
|
|
@ -105,6 +101,8 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
return nil, fmt.Errorf("tiki plugin has too many lanes (%d), max is 10", len(cfg.Lanes))
|
||||
}
|
||||
|
||||
parser := ruki.NewParser(schema)
|
||||
|
||||
lanes := make([]TikiLane, 0, len(cfg.Lanes))
|
||||
for i, lane := range cfg.Lanes {
|
||||
if lane.Name == "" {
|
||||
|
|
@ -120,26 +118,35 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
if lane.Width < 0 || lane.Width > 100 {
|
||||
return nil, fmt.Errorf("lane %q has invalid width %d (must be 0-100)", lane.Name, lane.Width)
|
||||
}
|
||||
filterExpr, err := filter.ParseFilter(lane.Filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
|
||||
}
|
||||
if filterExpr != nil {
|
||||
reg := config.GetStatusRegistry()
|
||||
if err := filter.ValidateFilterStatuses(filterExpr, reg.IsValid); err != nil {
|
||||
return nil, fmt.Errorf("view %q, lane %q: %w", cfg.Name, lane.Name, err)
|
||||
|
||||
var filterStmt *ruki.ValidatedStatement
|
||||
if lane.Filter != "" {
|
||||
filterStmt, err = parser.ParseAndValidateStatement(lane.Filter, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
|
||||
}
|
||||
if !filterStmt.IsSelect() {
|
||||
return nil, fmt.Errorf("lane %q filter must be a SELECT statement", lane.Name)
|
||||
}
|
||||
}
|
||||
action, err := ParseLaneAction(lane.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
|
||||
|
||||
var actionStmt *ruki.ValidatedStatement
|
||||
if lane.Action != "" {
|
||||
actionStmt, err = parser.ParseAndValidateStatement(lane.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
|
||||
}
|
||||
if !actionStmt.IsUpdate() {
|
||||
return nil, fmt.Errorf("lane %q action must be an UPDATE statement", lane.Name)
|
||||
}
|
||||
}
|
||||
|
||||
lanes = append(lanes, TikiLane{
|
||||
Name: lane.Name,
|
||||
Columns: columns,
|
||||
Width: lane.Width,
|
||||
Filter: filterExpr,
|
||||
Action: action,
|
||||
Filter: filterStmt,
|
||||
Action: actionStmt,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -152,14 +159,8 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
slog.Warn("lane widths sum exceeds 100%", "plugin", cfg.Name, "sum", widthSum)
|
||||
}
|
||||
|
||||
// Parse sort rules
|
||||
sortRules, err := ParseSort(cfg.Sort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing sort: %w", err)
|
||||
}
|
||||
|
||||
// Parse plugin actions
|
||||
actions, err := parsePluginActions(cfg.Actions)
|
||||
actions, err := parsePluginActions(cfg.Actions, parser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q (%s): %w", cfg.Name, source, err)
|
||||
}
|
||||
|
|
@ -167,7 +168,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
return &TikiPlugin{
|
||||
BasePlugin: base,
|
||||
Lanes: lanes,
|
||||
Sort: sortRules,
|
||||
ViewMode: cfg.View,
|
||||
Actions: actions,
|
||||
}, nil
|
||||
|
|
@ -178,7 +178,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
}
|
||||
|
||||
// parsePluginActions parses and validates plugin action configs into PluginAction slice.
|
||||
func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
|
||||
func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]PluginAction, error) {
|
||||
if len(configs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -212,18 +212,18 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
|
|||
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction(cfg.Action)
|
||||
actionStmt, err := parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
if len(action.Ops) == 0 {
|
||||
return nil, fmt.Errorf("action %d (key %q) has empty action expression", i, cfg.Key)
|
||||
if actionStmt.IsSelect() {
|
||||
return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, or DELETE — not SELECT", i, cfg.Key)
|
||||
}
|
||||
|
||||
actions = append(actions, PluginAction{
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: action,
|
||||
Action: actionStmt,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -231,11 +231,11 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
|
|||
}
|
||||
|
||||
// parsePluginYAML parses plugin YAML data into a Plugin
|
||||
func parsePluginYAML(data []byte, source string) (Plugin, error) {
|
||||
func parsePluginYAML(data []byte, source string, schema ruki.Schema) (Plugin, error) {
|
||||
var cfg pluginFileConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing yaml: %w", err)
|
||||
}
|
||||
|
||||
return parsePluginConfig(cfg, source)
|
||||
return parsePluginConfig(cfg, source, schema)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,22 @@ package plugin
|
|||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
func testSchema() ruki.Schema {
|
||||
return rukiRuntime.NewSchema()
|
||||
}
|
||||
|
||||
func testParser() *ruki.Parser {
|
||||
return ruki.NewParser(testSchema())
|
||||
}
|
||||
|
||||
func TestDokiValidation(t *testing.T) {
|
||||
schema := testSchema()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg pluginFileConfig
|
||||
|
|
@ -81,7 +94,7 @@ func TestDokiValidation(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parsePluginConfig(tc.cfg, "test")
|
||||
_, err := parsePluginConfig(tc.cfg, "test", schema)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
|
||||
|
|
@ -98,6 +111,8 @@ func TestDokiValidation(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTikiValidation(t *testing.T) {
|
||||
schema := testSchema()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg pluginFileConfig
|
||||
|
|
@ -108,18 +123,22 @@ func TestTikiValidation(t *testing.T) {
|
|||
cfg: pluginFileConfig{
|
||||
Name: "Tiki with Fetcher",
|
||||
Type: "tiki",
|
||||
Filter: "status='ready'",
|
||||
Fetcher: "file",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
},
|
||||
wantError: "tiki plugin cannot have 'fetcher'",
|
||||
},
|
||||
{
|
||||
name: "Tiki with Doki fields (Text)",
|
||||
cfg: pluginFileConfig{
|
||||
Name: "Tiki with Text",
|
||||
Type: "tiki",
|
||||
Filter: "status='ready'",
|
||||
Text: "text",
|
||||
Name: "Tiki with Text",
|
||||
Type: "tiki",
|
||||
Text: "text",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
},
|
||||
wantError: "tiki plugin cannot have 'text'",
|
||||
},
|
||||
|
|
@ -127,7 +146,7 @@ func TestTikiValidation(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parsePluginConfig(tc.cfg, "test")
|
||||
_, err := parsePluginConfig(tc.cfg, "test", schema)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
|
||||
|
|
@ -150,7 +169,7 @@ func TestParsePluginConfig_InvalidKey(t *testing.T) {
|
|||
Type: "tiki",
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid key format")
|
||||
}
|
||||
|
|
@ -165,12 +184,12 @@ func TestParsePluginConfig_DefaultTikiType(t *testing.T) {
|
|||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: "status='ready'"},
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
// Type not specified, should default to "tiki"
|
||||
}
|
||||
|
||||
plugin, err := parsePluginConfig(cfg, "test.yaml")
|
||||
plugin, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -187,7 +206,7 @@ func TestParsePluginConfig_UnknownType(t *testing.T) {
|
|||
Type: "unknown",
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unknown plugin type")
|
||||
}
|
||||
|
|
@ -206,7 +225,7 @@ func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
|
|||
Filter: "invalid ( filter",
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid top-level filter")
|
||||
}
|
||||
|
|
@ -219,26 +238,6 @@ func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
|
|||
// TestParsePluginConfig_TikiWithInvalidSort removed - the sort parser is very lenient
|
||||
// and accepts most field names. Invalid syntax would be caught by ParseSort internally.
|
||||
|
||||
func TestParsePluginConfig_DokiWithSort(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Type: "doki",
|
||||
Fetcher: "internal",
|
||||
Text: "content",
|
||||
Sort: "Priority", // Doki shouldn't have sort
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for doki with sort field")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "doki plugin cannot have 'sort'") {
|
||||
t.Errorf("Expected 'cannot have sort' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_DokiWithView(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
|
|
@ -249,7 +248,7 @@ func TestParsePluginConfig_DokiWithView(t *testing.T) {
|
|||
View: "expanded", // Doki shouldn't have view
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for doki with view field")
|
||||
}
|
||||
|
|
@ -262,7 +261,7 @@ func TestParsePluginConfig_DokiWithView(t *testing.T) {
|
|||
func TestParsePluginYAML_InvalidYAML(t *testing.T) {
|
||||
invalidYAML := []byte("invalid: yaml: content:")
|
||||
|
||||
_, err := parsePluginYAML(invalidYAML, "test.yaml")
|
||||
_, err := parsePluginYAML(invalidYAML, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid YAML")
|
||||
}
|
||||
|
|
@ -280,14 +279,13 @@ type: tiki
|
|||
lanes:
|
||||
- name: Todo
|
||||
columns: 4
|
||||
filter: status = 'ready'
|
||||
sort: Priority
|
||||
filter: select where status = "ready"
|
||||
view: expanded
|
||||
foreground: "#ff0000"
|
||||
background: "#0000ff"
|
||||
`)
|
||||
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml")
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -315,12 +313,14 @@ background: "#0000ff"
|
|||
}
|
||||
|
||||
func TestParsePluginActions_Valid(t *testing.T) {
|
||||
parser := testParser()
|
||||
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Add to board", Action: "status = 'ready'"},
|
||||
{Key: "a", Label: "Assign to me", Action: "assignee = CURRENT_USER"},
|
||||
{Key: "b", Label: "Add to board", Action: `update where id = id() set status="ready"`},
|
||||
{Key: "a", Label: "Assign to me", Action: `update where id = id() set assignee=user()`},
|
||||
}
|
||||
|
||||
actions, err := parsePluginActions(configs)
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -335,23 +335,28 @@ func TestParsePluginActions_Valid(t *testing.T) {
|
|||
if actions[0].Label != "Add to board" {
|
||||
t.Errorf("expected label 'Add to board', got %q", actions[0].Label)
|
||||
}
|
||||
if len(actions[0].Action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(actions[0].Action.Ops))
|
||||
if actions[0].Action == nil {
|
||||
t.Fatal("expected non-nil action")
|
||||
}
|
||||
if actions[0].Action.Ops[0].Field != ActionFieldStatus {
|
||||
t.Errorf("expected status field, got %v", actions[0].Action.Ops[0].Field)
|
||||
}
|
||||
if actions[0].Action.Ops[0].StrValue != "ready" {
|
||||
t.Errorf("expected 'ready', got %q", actions[0].Action.Ops[0].StrValue)
|
||||
if !actions[0].Action.IsUpdate() {
|
||||
t.Error("expected action to be an UPDATE statement")
|
||||
}
|
||||
|
||||
if actions[1].Rune != 'a' {
|
||||
t.Errorf("expected rune 'a', got %q", actions[1].Rune)
|
||||
}
|
||||
if actions[1].Action == nil {
|
||||
t.Fatal("expected non-nil action for 'assign to me'")
|
||||
}
|
||||
if !actions[1].Action.IsUpdate() {
|
||||
t.Error("expected 'assign to me' action to be an UPDATE statement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_Empty(t *testing.T) {
|
||||
actions, err := parsePluginActions(nil)
|
||||
parser := testParser()
|
||||
|
||||
actions, err := parsePluginActions(nil, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -361,6 +366,8 @@ func TestParsePluginActions_Empty(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParsePluginActions_Errors(t *testing.T) {
|
||||
parser := testParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configs []PluginActionConfig
|
||||
|
|
@ -368,17 +375,17 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "missing key",
|
||||
configs: []PluginActionConfig{{Key: "", Label: "Test", Action: "status=ready"}},
|
||||
configs: []PluginActionConfig{{Key: "", Label: "Test", Action: `update where id = id() set status="ready"`}},
|
||||
wantErr: "missing 'key'",
|
||||
},
|
||||
{
|
||||
name: "multi-character key",
|
||||
configs: []PluginActionConfig{{Key: "ab", Label: "Test", Action: "status=ready"}},
|
||||
configs: []PluginActionConfig{{Key: "ab", Label: "Test", Action: `update where id = id() set status="ready"`}},
|
||||
wantErr: "single character",
|
||||
},
|
||||
{
|
||||
name: "missing label",
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "", Action: "status=ready"}},
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "", Action: `update where id = id() set status="ready"`}},
|
||||
wantErr: "missing 'label'",
|
||||
},
|
||||
{
|
||||
|
|
@ -388,14 +395,14 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "invalid action expression",
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "Test", Action: "owner=me"}},
|
||||
wantErr: "unknown action field",
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "Test", Action: `update where id = id() set owner="me"`}},
|
||||
wantErr: "parsing action",
|
||||
},
|
||||
{
|
||||
name: "duplicate key",
|
||||
configs: []PluginActionConfig{
|
||||
{Key: "b", Label: "First", Action: "status=ready"},
|
||||
{Key: "b", Label: "Second", Action: "status=done"},
|
||||
{Key: "b", Label: "First", Action: `update where id = id() set status="ready"`},
|
||||
{Key: "b", Label: "Second", Action: `update where id = id() set status="done"`},
|
||||
},
|
||||
wantErr: "duplicate action key",
|
||||
},
|
||||
|
|
@ -404,7 +411,7 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
configs: func() []PluginActionConfig {
|
||||
configs := make([]PluginActionConfig, 11)
|
||||
for i := range configs {
|
||||
configs[i] = PluginActionConfig{Key: string(rune('a' + i)), Label: "Test", Action: "status=ready"}
|
||||
configs[i] = PluginActionConfig{Key: string(rune('a' + i)), Label: "Test", Action: `update where id = id() set status="ready"`}
|
||||
}
|
||||
return configs
|
||||
}(),
|
||||
|
|
@ -414,7 +421,7 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parsePluginActions(tc.configs)
|
||||
_, err := parsePluginActions(tc.configs, parser)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||
}
|
||||
|
|
@ -431,15 +438,14 @@ name: Test
|
|||
key: T
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: status = 'backlog'
|
||||
filter: select where status = "backlog"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority
|
||||
action: update where id = id() set status = "ready"
|
||||
`)
|
||||
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml")
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -469,11 +475,11 @@ func TestParsePluginConfig_DokiWithActions(t *testing.T) {
|
|||
Fetcher: "internal",
|
||||
Text: "content",
|
||||
Actions: []PluginActionConfig{
|
||||
{Key: "b", Label: "Test", Action: "status=ready"},
|
||||
{Key: "b", Label: "Test", Action: `update where id = id() set status="ready"`},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for doki with actions")
|
||||
}
|
||||
|
|
@ -492,7 +498,7 @@ url: http://example.com/doc
|
|||
foreground: "#00ff00"
|
||||
`)
|
||||
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml")
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
|
|||
134
plugin/sort.go
134
plugin/sort.go
|
|
@ -1,134 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// SortRule represents a single sort criterion
|
||||
type SortRule struct {
|
||||
Field string // "Assignee", "Points", "Priority", "CreatedAt", "UpdatedAt", "Status", "Type", "Title"
|
||||
Descending bool // true for DESC, false for ASC (default)
|
||||
}
|
||||
|
||||
// ParseSort parses a sort expression like "Assignee, Points DESC, Priority DESC, CreatedAt"
|
||||
func ParseSort(expr string) ([]SortRule, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil, nil // no custom sorting
|
||||
}
|
||||
|
||||
var rules []SortRule
|
||||
parts := strings.Split(expr, ",")
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(part)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rule := SortRule{
|
||||
Field: normalizeFieldName(fields[0]),
|
||||
Descending: false,
|
||||
}
|
||||
|
||||
if len(fields) > 1 && strings.ToUpper(fields[1]) == "DESC" {
|
||||
rule.Descending = true
|
||||
}
|
||||
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// normalizeFieldName normalizes field names to a canonical form
|
||||
func normalizeFieldName(field string) string {
|
||||
switch strings.ToLower(field) {
|
||||
case "assignee":
|
||||
return "assignee"
|
||||
case "points":
|
||||
return "points"
|
||||
case "priority":
|
||||
return "priority"
|
||||
case "createdat":
|
||||
return "createdat"
|
||||
case "updatedat":
|
||||
return "updatedat"
|
||||
case "status":
|
||||
return "status"
|
||||
case "type":
|
||||
return "type"
|
||||
case "title":
|
||||
return "title"
|
||||
case "id":
|
||||
return "id"
|
||||
default:
|
||||
return strings.ToLower(field)
|
||||
}
|
||||
}
|
||||
|
||||
// SortTasks sorts tasks according to the given sort rules
|
||||
func SortTasks(tasks []*task.Task, rules []SortRule) {
|
||||
if len(rules) == 0 {
|
||||
return // preserve original order
|
||||
}
|
||||
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
for _, rule := range rules {
|
||||
cmp := compareByField(tasks[i], tasks[j], rule.Field)
|
||||
if cmp != 0 {
|
||||
if rule.Descending {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
}
|
||||
}
|
||||
return false // equal by all criteria, preserve order
|
||||
})
|
||||
}
|
||||
|
||||
// compareByField compares two tasks by a specific field
|
||||
// Returns negative if a < b, 0 if equal, positive if a > b
|
||||
func compareByField(a, b *task.Task, field string) int {
|
||||
switch field {
|
||||
case "assignee":
|
||||
return strings.Compare(strings.ToLower(a.Assignee), strings.ToLower(b.Assignee))
|
||||
case "points":
|
||||
return a.Points - b.Points
|
||||
case "priority":
|
||||
return a.Priority - b.Priority
|
||||
case "createdat":
|
||||
if a.CreatedAt.Before(b.CreatedAt) {
|
||||
return -1
|
||||
} else if a.CreatedAt.After(b.CreatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "updatedat":
|
||||
if a.UpdatedAt.Before(b.UpdatedAt) {
|
||||
return -1
|
||||
} else if a.UpdatedAt.After(b.UpdatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "status":
|
||||
return strings.Compare(string(a.Status), string(b.Status))
|
||||
case "type":
|
||||
return strings.Compare(string(a.Type), string(b.Type))
|
||||
case "title":
|
||||
return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
|
||||
case "id":
|
||||
// Sort IDs lexicographically (alphanumeric IDs)
|
||||
return strings.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestSortTasks_NoRules(t *testing.T) {
|
||||
tasks := []*task.Task{
|
||||
{ID: "TIKI-C", Priority: 3},
|
||||
{ID: "TIKI-A", Priority: 1},
|
||||
{ID: "TIKI-B", Priority: 2},
|
||||
}
|
||||
SortTasks(tasks, nil)
|
||||
// original order preserved
|
||||
if tasks[0].ID != "TIKI-C" || tasks[1].ID != "TIKI-A" || tasks[2].ID != "TIKI-B" {
|
||||
t.Errorf("expected original order preserved, got %v %v %v", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortTasks_ByField(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-time.Hour)
|
||||
later := now.Add(time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tasks []*task.Task
|
||||
rules []SortRule
|
||||
expectedID []string
|
||||
}{
|
||||
{
|
||||
name: "priority ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-C", Priority: 3},
|
||||
{ID: "TIKI-A", Priority: 1},
|
||||
{ID: "TIKI-B", Priority: 2},
|
||||
},
|
||||
rules: []SortRule{{Field: "priority", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"},
|
||||
},
|
||||
{
|
||||
name: "priority DESC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-A", Priority: 1},
|
||||
{ID: "TIKI-B", Priority: 2},
|
||||
{ID: "TIKI-C", Priority: 3},
|
||||
},
|
||||
rules: []SortRule{{Field: "priority", Descending: true}},
|
||||
expectedID: []string{"TIKI-C", "TIKI-B", "TIKI-A"},
|
||||
},
|
||||
{
|
||||
name: "title ASC case-insensitive",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-Z", Title: "Zebra"},
|
||||
{ID: "TIKI-A", Title: "apple"},
|
||||
{ID: "TIKI-M", Title: "Mango"},
|
||||
},
|
||||
rules: []SortRule{{Field: "title", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"},
|
||||
},
|
||||
{
|
||||
name: "points ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-H", Points: 8},
|
||||
{ID: "TIKI-L", Points: 1},
|
||||
{ID: "TIKI-M", Points: 5},
|
||||
},
|
||||
rules: []SortRule{{Field: "points", Descending: false}},
|
||||
expectedID: []string{"TIKI-L", "TIKI-M", "TIKI-H"},
|
||||
},
|
||||
{
|
||||
name: "assignee ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-Z", Assignee: "Zara"},
|
||||
{ID: "TIKI-A", Assignee: "alice"},
|
||||
{ID: "TIKI-M", Assignee: "Bob"},
|
||||
},
|
||||
rules: []SortRule{{Field: "assignee", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"},
|
||||
},
|
||||
{
|
||||
name: "status ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-R", Status: "ready"},
|
||||
{ID: "TIKI-B", Status: "backlog"},
|
||||
{ID: "TIKI-D", Status: "done"},
|
||||
},
|
||||
rules: []SortRule{{Field: "status", Descending: false}},
|
||||
expectedID: []string{"TIKI-B", "TIKI-D", "TIKI-R"},
|
||||
},
|
||||
{
|
||||
name: "type ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-S", Type: task.TypeStory},
|
||||
{ID: "TIKI-B", Type: task.TypeBug},
|
||||
},
|
||||
rules: []SortRule{{Field: "type", Descending: false}},
|
||||
expectedID: []string{"TIKI-B", "TIKI-S"},
|
||||
},
|
||||
{
|
||||
name: "id ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-C"},
|
||||
{ID: "TIKI-A"},
|
||||
{ID: "TIKI-B"},
|
||||
},
|
||||
rules: []SortRule{{Field: "id", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"},
|
||||
},
|
||||
{
|
||||
name: "createdat ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-L", CreatedAt: later},
|
||||
{ID: "TIKI-E", CreatedAt: earlier},
|
||||
{ID: "TIKI-N", CreatedAt: now},
|
||||
},
|
||||
rules: []SortRule{{Field: "createdat", Descending: false}},
|
||||
expectedID: []string{"TIKI-E", "TIKI-N", "TIKI-L"},
|
||||
},
|
||||
{
|
||||
name: "updatedat DESC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-E", UpdatedAt: earlier},
|
||||
{ID: "TIKI-L", UpdatedAt: later},
|
||||
{ID: "TIKI-N", UpdatedAt: now},
|
||||
},
|
||||
rules: []SortRule{{Field: "updatedat", Descending: true}},
|
||||
expectedID: []string{"TIKI-L", "TIKI-N", "TIKI-E"},
|
||||
},
|
||||
{
|
||||
name: "multi-rule: priority ASC then title ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-B2", Priority: 2, Title: "Beta"},
|
||||
{ID: "TIKI-A2", Priority: 2, Title: "Alpha"},
|
||||
{ID: "TIKI-A1", Priority: 1, Title: "Zeta"},
|
||||
},
|
||||
rules: []SortRule{
|
||||
{Field: "priority", Descending: false},
|
||||
{Field: "title", Descending: false},
|
||||
},
|
||||
expectedID: []string{"TIKI-A1", "TIKI-A2", "TIKI-B2"},
|
||||
},
|
||||
{
|
||||
name: "unknown field — equal comparison, stable order preserved",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-X"},
|
||||
{ID: "TIKI-Y"},
|
||||
},
|
||||
rules: []SortRule{{Field: "nonexistent", Descending: false}},
|
||||
expectedID: []string{"TIKI-X", "TIKI-Y"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
SortTasks(tt.tasks, tt.rules)
|
||||
|
||||
if len(tt.tasks) != len(tt.expectedID) {
|
||||
t.Fatalf("task count = %d, want %d", len(tt.tasks), len(tt.expectedID))
|
||||
}
|
||||
for i, want := range tt.expectedID {
|
||||
if tt.tasks[i].ID != want {
|
||||
t.Errorf("tasks[%d].ID = %q, want %q", i, tt.tasks[i].ID, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,19 @@ func (v *ValidatedStatement) RequiresCreateTemplate() bool {
|
|||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) IsSelect() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Select != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsUpdate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Update != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsCreate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsDelete() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Delete != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.statement == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "statement"}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
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/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/store/tikistore"
|
||||
|
|
@ -35,6 +37,7 @@ type TestApp struct {
|
|||
PluginControllers map[string]controller.PluginControllerInterface
|
||||
PluginDefs []plugin.Plugin
|
||||
MutationGate *service.TaskMutationGate
|
||||
Schema ruki.Schema
|
||||
taskController *controller.TaskController
|
||||
statuslineConfig *model.StatuslineConfig
|
||||
headerConfig *model.HeaderConfig
|
||||
|
|
@ -61,6 +64,9 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
config.ResetPathManager()
|
||||
})
|
||||
|
||||
// 0.5. Create ruki schema (needed by plugin parser and controllers)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
|
||||
// 1. Create temp dir for task files (auto-cleanup via t.TempDir())
|
||||
taskDir := t.TempDir()
|
||||
|
||||
|
|
@ -100,6 +106,7 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
taskStore,
|
||||
gate,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
|
||||
// 6. Initialize View Layer
|
||||
|
|
@ -166,6 +173,7 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
RootLayout: rootLayout,
|
||||
TaskStore: taskStore,
|
||||
MutationGate: gate,
|
||||
Schema: schema,
|
||||
NavController: navController,
|
||||
InputRouter: inputRouter,
|
||||
TaskDir: taskDir,
|
||||
|
|
@ -314,7 +322,7 @@ func (ta *TestApp) Cleanup() {
|
|||
// This enables testing of plugin-related functionality.
|
||||
func (ta *TestApp) LoadPlugins() error {
|
||||
// Load embedded plugins
|
||||
plugins, err := plugin.LoadPlugins()
|
||||
plugins, err := plugin.LoadPlugins(ta.Schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -338,7 +346,7 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
}
|
||||
pc.SetLaneLayout(columns, widths)
|
||||
pluginControllers[p.GetName()] = controller.NewPluginController(
|
||||
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig,
|
||||
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig, ta.Schema,
|
||||
)
|
||||
} else if dp, ok := p.(*plugin.DokiPlugin); ok {
|
||||
pluginControllers[p.GetName()] = controller.NewDokiController(
|
||||
|
|
@ -373,6 +381,7 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
ta.TaskStore,
|
||||
ta.MutationGate,
|
||||
ta.statuslineConfig,
|
||||
ta.Schema,
|
||||
)
|
||||
|
||||
// Update global input capture to handle plugin switching keys
|
||||
|
|
|
|||
Loading…
Reference in a new issue