diff --git a/.doc/doki/doc/config.md b/.doc/doki/doc/config.md index a6c2bac..4e82b3b 100644 --- a/.doc/doki/doc/config.md +++ b/.doc/doki/doc/config.md @@ -66,12 +66,15 @@ Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd - Empty/zero fields in the override are ignored — the base value is kept - Views that only exist in the override are appended +**Global plugin actions** (`views.actions`) — merged by key across files. If two files define a global action with the same key, the later file's action wins. Global actions are appended to each tiki plugin's action list; per-plugin actions with the same key take precedence. + A project only needs to define the views or fields it wants to change. Everything else is inherited from your user config. To disable all user-level views for a project, create a `.doc/workflow.yaml` with an explicitly empty views list: ```yaml -views: [] +views: + plugins: [] ``` ### config.yaml @@ -147,70 +150,111 @@ statuses: done: true views: - - name: Kanban - description: "Move tiki to new status, search, create or delete" - key: "F1" - lanes: - - name: 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: select where status = "inProgress" and type != "epic" order by priority, createdAt - action: update where id = id() set status="inProgress" - - name: Review - filter: select where status = "review" and type != "epic" order by priority, createdAt - action: update where id = id() set status="review" - - name: Done - 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" - key: "F3" - lanes: - - name: Backlog - columns: 4 - filter: select where status = "backlog" and type != "epic" order by priority, id - actions: - - key: "b" - label: "Add to board" - action: update where id = id() set status="ready" - - name: Recent - description: "Tasks changed in the last 24 hours, most recent first" - key: Ctrl-R - lanes: - - name: Recent - columns: 4 - filter: select where now() - updatedAt < 24hour order by updatedAt desc - - name: Roadmap - description: "Epics organized by Now, Next, and Later horizons" - key: "F4" - lanes: - - name: Now - columns: 1 - width: 25 - 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: 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: 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" - type: doki - fetcher: internal - text: "Help" - key: "?" - - name: Docs - description: "Project notes and documentation files" - type: doki - fetcher: file - url: "index.md" - key: "F2" + actions: + - key: "a" + label: "Assign to me" + action: update where id = id() set assignee=user() + plugins: + - name: Kanban + description: "Move tiki to new status, search, create or delete" + key: "F1" + lanes: + - name: 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: select where status = "inProgress" and type != "epic" order by priority, createdAt + action: update where id = id() set status="inProgress" + - name: Review + filter: select where status = "review" and type != "epic" order by priority, createdAt + action: update where id = id() set status="review" + - name: Done + 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" + key: "F3" + lanes: + - name: Backlog + columns: 4 + filter: select where status = "backlog" and type != "epic" order by priority, id + actions: + - key: "b" + label: "Add to board" + action: update where id = id() set status="ready" + - name: Recent + description: "Tasks changed in the last 24 hours, most recent first" + key: Ctrl-R + lanes: + - name: Recent + columns: 4 + filter: select where now() - updatedAt < 24hour order by updatedAt desc + - name: Roadmap + description: "Epics organized by Now, Next, and Later horizons" + key: "F4" + lanes: + - name: Now + columns: 1 + width: 25 + 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: 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: 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" + type: doki + fetcher: internal + text: "Help" + key: "?" + - name: Docs + description: "Project notes and documentation files" + type: doki + fetcher: file + url: "index.md" + key: "F2" + +triggers: + - description: block completion with open dependencies + ruki: > + before update + where new.status = "done" and new.dependsOn any status != "done" + deny "cannot complete: has open dependencies" + - description: tasks must pass through review before completion + ruki: > + before update + where new.status = "done" and old.status != "review" + deny "tasks must go through review before marking done" + - description: remove deleted task from dependency lists + ruki: > + after delete + update where old.id in dependsOn set dependsOn=dependsOn - [old.id] + - description: clean up completed tasks after 24 hours + ruki: > + every 1day + delete where status = "done" and updatedAt < now() - 1day + - description: tasks must have an assignee before starting + ruki: > + before update + where new.status = "inProgress" and new.assignee is empty + deny "assign someone before moving to in-progress" + - description: auto-complete epics when all child tasks finish + ruki: > + after update + where new.status = "done" and new.type != "epic" + update where type = "epic" and new.id in dependsOn and dependsOn all status = "done" + set status="done" + - description: cannot delete tasks that are actively being worked + ruki: > + before delete + where old.status = "inProgress" + deny "cannot delete an in-progress task — move to backlog or done first" ``` \ No newline at end of file diff --git a/.doc/doki/doc/customization.md b/.doc/doki/doc/customization.md index 233e047..05ef4ff 100644 --- a/.doc/doki/doc/customization.md +++ b/.doc/doki/doc/customization.md @@ -69,17 +69,18 @@ how Backlog is defined: ```yaml views: - - name: Backlog - description: "Tasks waiting to be picked up, sorted by priority" - key: "F3" - lanes: - - name: Backlog - columns: 4 - filter: select where status = "backlog" and type != "epic" order by priority, id - actions: - - key: "b" - label: "Add to board" - action: update where id = id() set status="ready" + plugins: + - name: Backlog + description: "Tasks waiting to be picked up, sorted by priority" + key: "F3" + lanes: + - name: Backlog + columns: 4 + filter: select where status = "backlog" and type != "epic" order by priority, id + actions: + - key: "b" + label: "Add to board" + action: update where id = id() set status="ready" ``` that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane. @@ -90,12 +91,13 @@ Likewise the documentation is just a plugin: ```yaml views: - - name: Docs - description: "Project notes and documentation files" - type: doki - fetcher: file - url: "index.md" - key: "F2" + plugins: + - name: Docs + description: "Project notes and documentation files" + type: doki + fetcher: file + url: "index.md" + key: "F2" ``` that translates to - show `index.md` file located under `.doc/doki` @@ -151,7 +153,28 @@ lanes: If no lanes specify width, all lanes are equally sized (the default behavior). -### Plugin actions +### Global plugin actions + +You can define actions under `views.actions` that are available in **all** tiki plugin views. This avoids repeating common shortcuts in every plugin definition. + +```yaml +views: + actions: + - key: "a" + label: "Assign to me" + action: update where id = id() set assignee=user() + plugins: + - name: Kanban + ... + - name: Backlog + ... +``` + +Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key as a global action, the per-plugin action takes precedence for that view. + +When multiple workflow files define `views.actions`, they merge by key across files — later files override same-keyed globals from earlier files. + +### Per-plugin actions In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active. @@ -169,14 +192,16 @@ actions: Each action has: - `key` - a single printable character used as the keyboard shortcut - `label` - description shown in the header -- `action` - a `ruki` `update` statement (same syntax as lane actions, see below) +- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`) When the shortcut key is pressed, the action is applied to the currently selected tiki. For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board. +`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki. + ### ruki expressions -Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements and actions use `update` statements. +Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored). #### Filter (select) diff --git a/.doc/doki/doc/ideas/plugins.md b/.doc/doki/doc/ideas/plugins.md index 9521c8c..1dba1af 100644 --- a/.doc/doki/doc/ideas/plugins.md +++ b/.doc/doki/doc/ideas/plugins.md @@ -13,17 +13,20 @@ - [Priority triage](#priority-triage--five-lane-plugin) - [By topic](#by-topic--tag-based-lanes) -## Assign to me — plugin action +## Assign to me — global plugin action -Shortcut key that sets the selected task's assignee to the current git user. +Shortcut key that sets the selected task's assignee to the current git user. Defined under `views.actions`, this shortcut is available in all tiki plugin views. ```yaml -actions: - - key: "a" - label: "Assign to me" - action: update where id = id() set assignee=user() +views: + actions: + - key: "a" + label: "Assign to me" + action: update where id = id() set assignee=user() ``` +The same format works as a per-plugin action (under a plugin's `actions:` key) if you only want it in a specific view. + ## Add tag to task — plugin action Appends a tag to the selected task's tag list without removing existing tags. diff --git a/config/default_workflow.yaml b/config/default_workflow.yaml index 353d662..424d4fa 100644 --- a/config/default_workflow.yaml +++ b/config/default_workflow.yaml @@ -21,73 +21,78 @@ statuses: done: true views: - - name: Kanban - description: "Move tiki to new status, search, create or delete" - default: true - key: "F1" - lanes: - - name: 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: select where status = "inProgress" and type != "epic" order by priority, createdAt - action: update where id = id() set status="inProgress" - - name: Review - filter: select where status = "review" and type != "epic" order by priority, createdAt - action: update where id = id() set status="review" - - name: Done - 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" - key: "F3" - lanes: - - name: Backlog - columns: 4 - filter: select where status = "backlog" and type != "epic" order by priority, id - actions: - - key: "b" - label: "Add to board" - action: update where id = id() set status="ready" - - name: Recent - description: "Tasks changed in the last 24 hours, most recent first" - key: Ctrl-R - lanes: - - name: Recent - columns: 4 - filter: select where now() - updatedAt < 24hour order by updatedAt desc - - name: Roadmap - description: "Epics organized by Now, Next, and Later horizons" - key: "F4" - lanes: - - name: Now - columns: 1 - width: 25 - 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: 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: 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" - type: doki - fetcher: internal - text: "Help" - key: "?" - - name: Docs - description: "Project notes and documentation files" - type: doki - fetcher: file - url: "index.md" - key: "F2" + actions: + - key: "a" + label: "Assign to me" + action: update where id = id() set assignee=user() + plugins: + - name: Kanban + description: "Move tiki to new status, search, create or delete" + default: true + key: "F1" + lanes: + - name: 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: select where status = "inProgress" and type != "epic" order by priority, createdAt + action: update where id = id() set status="inProgress" + - name: Review + filter: select where status = "review" and type != "epic" order by priority, createdAt + action: update where id = id() set status="review" + - name: Done + 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" + key: "F3" + lanes: + - name: Backlog + columns: 4 + filter: select where status = "backlog" and type != "epic" order by priority, id + actions: + - key: "b" + label: "Add to board" + action: update where id = id() set status="ready" + - name: Recent + description: "Tasks changed in the last 24 hours, most recent first" + key: Ctrl-R + lanes: + - name: Recent + columns: 4 + filter: select where now() - updatedAt < 24hour order by updatedAt desc + - name: Roadmap + description: "Epics organized by Now, Next, and Later horizons" + key: "F4" + lanes: + - name: Now + columns: 1 + width: 25 + 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: 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: 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" + type: doki + fetcher: internal + text: "Help" + key: "?" + - name: Docs + description: "Project notes and documentation files" + type: doki + fetcher: file + url: "index.md" + key: "F2" triggers: - description: block completion with open dependencies diff --git a/config/loader.go b/config/loader.go index d86c83a..9b0cb50 100644 --- a/config/loader.go +++ b/config/loader.go @@ -190,21 +190,40 @@ func GetConfig() *Config { return appConfig } +// viewsFileData represents the views section of workflow.yaml for read-modify-write. +type viewsFileData struct { + Actions []map[string]interface{} `yaml:"actions,omitempty"` + Plugins []map[string]interface{} `yaml:"plugins"` +} + // workflowFileData represents the YAML structure of workflow.yaml for read-modify-write. // kept in config package to avoid import cycle with plugin package. // all top-level sections must be listed here to survive round-trip serialization. type workflowFileData struct { Statuses []map[string]interface{} `yaml:"statuses,omitempty"` - Plugins []map[string]interface{} `yaml:"views"` + Views viewsFileData `yaml:"views,omitempty"` Triggers []map[string]interface{} `yaml:"triggers,omitempty"` } // readWorkflowFile reads and unmarshals workflow.yaml from the given path. +// Handles both old list format (views: [...]) and new map format (views: {plugins: [...]}). func readWorkflowFile(path string) (*workflowFileData, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading workflow.yaml: %w", err) } + + // convert legacy views list format to map format before unmarshaling + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing workflow.yaml: %w", err) + } + ConvertViewsListToMap(raw) + data, err = yaml.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("re-marshaling workflow.yaml: %w", err) + } + var wf workflowFileData if err := yaml.Unmarshal(data, &wf); err != nil { return nil, fmt.Errorf("parsing workflow.yaml: %w", err) @@ -212,6 +231,23 @@ func readWorkflowFile(path string) (*workflowFileData, error) { return &wf, nil } +// ConvertViewsListToMap converts old views list format to new map format in-place. +// Old: views: [{name: Kanban, ...}] → New: views: {plugins: [{name: Kanban, ...}]} +func ConvertViewsListToMap(raw map[string]interface{}) { + views, ok := raw["views"] + if !ok { + return + } + if _, isMap := views.(map[string]interface{}); isMap { + return + } + if list, isList := views.([]interface{}); isList { + raw["views"] = map[string]interface{}{ + "plugins": list, + } + } +} + // writeWorkflowFile marshals and writes workflow.yaml to the given path. func writeWorkflowFile(path string, wf *workflowFileData) error { data, err := yaml.Marshal(wf) @@ -250,7 +286,7 @@ func getPluginViewModeFromWorkflow(pluginName string, defaultValue string) strin return defaultValue } - for _, p := range wf.Plugins { + for _, p := range wf.Views.Plugins { if name, ok := p["name"].(string); ok && name == pluginName { if view, ok := p["view"].(string); ok && view != "" { return view @@ -279,13 +315,13 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err wf = &workflowFileData{} } - if configIndex >= 0 && configIndex < len(wf.Plugins) { + if configIndex >= 0 && configIndex < len(wf.Views.Plugins) { // update existing entry by index - wf.Plugins[configIndex]["view"] = viewMode + wf.Views.Plugins[configIndex]["view"] = viewMode } else { // find by name or create new entry existingIndex := -1 - for i, p := range wf.Plugins { + for i, p := range wf.Views.Plugins { if name, ok := p["name"].(string); ok && name == pluginName { existingIndex = i break @@ -293,13 +329,13 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err } if existingIndex >= 0 { - wf.Plugins[existingIndex]["view"] = viewMode + wf.Views.Plugins[existingIndex]["view"] = viewMode } else { newEntry := map[string]interface{}{ "name": pluginName, "view": viewMode, } - wf.Plugins = append(wf.Plugins, newEntry) + wf.Views.Plugins = append(wf.Views.Plugins, newEntry) } } diff --git a/config/loader_test.go b/config/loader_test.go index 383bdc5..c53e782 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -450,8 +450,8 @@ triggers: } // modify a view mode (same as SavePluginViewMode logic) - if len(wf.Plugins) > 0 { - wf.Plugins[0]["view"] = "compact" + if len(wf.Views.Plugins) > 0 { + wf.Views.Plugins[0]["view"] = "compact" } if err := writeWorkflowFile(workflowPath, wf); err != nil { diff --git a/config/paths.go b/config/paths.go index 2ab70e5..95e86c6 100644 --- a/config/paths.go +++ b/config/paths.go @@ -372,21 +372,29 @@ func FindWorkflowFiles() []string { return result } -// hasEmptyViews returns true if the workflow file has an explicit empty views list (views: []). +// hasEmptyViews returns true if the workflow file has an explicit empty views section. +// Handles both old list format (views: []) and new map format (views: {plugins: []}). func hasEmptyViews(path string) bool { data, err := os.ReadFile(path) if err != nil { return false } - type viewsOnly struct { - Views []any `yaml:"views"` + + var raw struct { + Views interface{} `yaml:"views"` } - var vo viewsOnly - if err := yaml.Unmarshal(data, &vo); err != nil { + if err := yaml.Unmarshal(data, &raw); err != nil { + return false + } + switch v := raw.Views.(type) { + case []interface{}: + return len(v) == 0 + case map[string]interface{}: + plugins, _ := v["plugins"].([]interface{}) + return plugins != nil && len(plugins) == 0 + default: return false } - // Explicitly empty (views: []) vs. not specified at all - return vo.Views != nil && len(vo.Views) == 0 } // FindWorkflowFile searches for workflow.yaml in config search paths. diff --git a/controller/plugin.go b/controller/plugin.go index 61e6054..e8268ea 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -159,7 +159,12 @@ func (pc *PluginController) handlePluginAction(r rune) bool { input := ruki.ExecutionInput{} taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane) - if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() { + if pa.Action.IsSelect() && !pa.Action.IsPipe() { + // plain SELECT actions are side-effect only — pass task ID if available but don't require it + if taskID != "" { + input.SelectedTaskID = taskID + } + } else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() { if taskID == "" { return false } @@ -183,6 +188,10 @@ func (pc *PluginController) handlePluginAction(r rune) bool { ctx := context.Background() switch { + case result.Select != nil: + slog.Info("select plugin action executed", "task_id", taskID, "key", string(r), + "label", pa.Label, "matched", len(result.Select.Tasks)) + return true case result.Update != nil: for _, updated := range result.Update.Updated { if err := pc.mutationGate.UpdateTask(ctx, updated); err != nil { diff --git a/controller/plugin_selection_test.go b/controller/plugin_selection_test.go index 78aeb94..2c72de2 100644 --- a/controller/plugin_selection_test.go +++ b/controller/plugin_selection_test.go @@ -1224,6 +1224,70 @@ func TestPluginController_HandleToggleViewMode(t *testing.T) { } } +func TestPluginController_HandlePluginAction_Select(t *testing.T) { + taskStore := store.NewInMemoryStore() + _ = taskStore.CreateTask(&task.Task{ + ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, + }) + + readyFilter := mustParseStmt(t, `select where status = "ready"`) + selectAction := mustParseStmt(t, `select where status = "ready"`) + + pluginDef := &plugin.TikiPlugin{ + BasePlugin: plugin.BasePlugin{Name: "TestPlugin"}, + Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}}, + Actions: []plugin.PluginAction{ + {Rune: 's', Label: "Search Ready", Action: selectAction}, + }, + } + pluginConfig := model.NewPluginConfig("TestPlugin") + pluginConfig.SetLaneLayout([]int{1}, nil) + pluginConfig.SetSelectedLane(0) + pluginConfig.SetSelectedIndexForLane(0, 0) + + schema := rukiRuntime.NewSchema() + gate := service.NewTaskMutationGate() + gate.SetStore(taskStore) + pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema) + + if !pc.HandleAction(pluginActionID('s')) { + t.Error("expected true for SELECT plugin action") + } + + // task should be unchanged (SELECT is side-effect only) + tk := taskStore.GetTask("T-1") + if tk.Status != task.StatusReady { + t.Errorf("expected status ready (unchanged), got %s", tk.Status) + } +} + +func TestPluginController_HandlePluginAction_SelectNoSelectedTask(t *testing.T) { + taskStore := store.NewInMemoryStore() + + emptyFilter := mustParseStmt(t, `select where status = "done"`) + selectAction := mustParseStmt(t, `select where status = "ready"`) + + pluginDef := &plugin.TikiPlugin{ + BasePlugin: plugin.BasePlugin{Name: "TestPlugin"}, + Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}}, + Actions: []plugin.PluginAction{ + {Rune: 's', Label: "Search Ready", Action: selectAction}, + }, + } + pluginConfig := model.NewPluginConfig("TestPlugin") + pluginConfig.SetLaneLayout([]int{1}, nil) + + schema := rukiRuntime.NewSchema() + gate := service.NewTaskMutationGate() + gate.SetStore(taskStore) + pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema) + + // SELECT should succeed even with no selected task + if !pc.HandleAction(pluginActionID('s')) { + t.Error("expected true for SELECT action even with no selected task") + } +} + func TestGetPluginActionRune(t *testing.T) { tests := []struct { name string diff --git a/plugin/legacy_convert.go b/plugin/legacy_convert.go index af67a0a..4f6ab8b 100644 --- a/plugin/legacy_convert.go +++ b/plugin/legacy_convert.go @@ -7,6 +7,7 @@ import ( "strings" "sync" + "github.com/boolean-maybe/tiki/config" "github.com/boolean-maybe/tiki/workflow" ) @@ -56,6 +57,12 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { return &LegacyConfigTransformer{} } +// ConvertViewsFormat converts the old views list format to the new map format in-place. +// Delegates to config.ConvertViewsListToMap. +func (t *LegacyConfigTransformer) ConvertViewsFormat(raw map[string]interface{}) { + config.ConvertViewsListToMap(raw) +} + // ConvertPluginConfig converts all legacy expressions in a plugin config in-place. // Returns the number of fields converted. func (t *LegacyConfigTransformer) ConvertPluginConfig(cfg *pluginFileConfig) int { @@ -133,7 +140,8 @@ func isRukiAction(expr string) bool { lower := strings.ToLower(strings.TrimSpace(expr)) return strings.HasPrefix(lower, "update") || strings.HasPrefix(lower, "create") || - strings.HasPrefix(lower, "delete") + strings.HasPrefix(lower, "delete") || + strings.HasPrefix(lower, "select") } // mergeSortIntoFilter appends an order-by clause to a filter, respecting existing order-by. diff --git a/plugin/legacy_convert_test.go b/plugin/legacy_convert_test.go index 72cf97f..7e07b77 100644 --- a/plugin/legacy_convert_test.go +++ b/plugin/legacy_convert_test.go @@ -612,20 +612,31 @@ func TestLegacyWorkflowEndToEnd(t *testing.T) { action: type = 'bug', priority = 1 ` + // convert legacy views format (list → map) before unmarshaling + var raw map[string]interface{} + if err := yaml.Unmarshal([]byte(legacyYAML), &raw); err != nil { + t.Fatalf("failed to unmarshal raw YAML: %v", err) + } + transformer := NewLegacyConfigTransformer() + transformer.ConvertViewsFormat(raw) + normalizedData, err := yaml.Marshal(raw) + if err != nil { + t.Fatalf("failed to re-marshal: %v", err) + } + var wf WorkflowFile - if err := yaml.Unmarshal([]byte(legacyYAML), &wf); err != nil { - t.Fatalf("failed to unmarshal legacy YAML: %v", err) + if err := yaml.Unmarshal(normalizedData, &wf); err != nil { + t.Fatalf("failed to unmarshal workflow YAML: %v", err) } // convert legacy expressions - transformer := NewLegacyConfigTransformer() - for i := range wf.Plugins { - transformer.ConvertPluginConfig(&wf.Plugins[i]) + for i := range wf.Views.Plugins { + transformer.ConvertPluginConfig(&wf.Views.Plugins[i]) } // parse into plugin — this validates ruki parsing succeeds schema := testSchema() - p, err := parsePluginConfig(wf.Plugins[0], "test", schema) + p, err := parsePluginConfig(wf.Views.Plugins[0], "test", schema) if err != nil { t.Fatalf("parsePluginConfig failed: %v", err) } @@ -703,7 +714,7 @@ func TestLegacyWorkflowEndToEnd(t *testing.T) { } // verify sort was merged: backlog filter should have order by - backlogFilter := wf.Plugins[0].Lanes[0].Filter + backlogFilter := wf.Views.Plugins[0].Lanes[0].Filter if !strings.Contains(backlogFilter, "order by") { t.Errorf("expected sort merged into backlog filter, got: %s", backlogFilter) } @@ -941,3 +952,67 @@ func TestConvertPluginConfig_PluginActionConvertError(t *testing.T) { t.Errorf("malformed plugin action should be passed through unchanged, got %q", cfg.Actions[0].Action) } } + +func TestConvertViewsFormat_ListToMap(t *testing.T) { + tr := NewLegacyConfigTransformer() + + raw := map[string]interface{}{ + "views": []interface{}{ + map[string]interface{}{"name": "Kanban"}, + map[string]interface{}{"name": "Backlog"}, + }, + } + tr.ConvertViewsFormat(raw) + + views, ok := raw["views"].(map[string]interface{}) + if !ok { + t.Fatalf("expected views to be a map after conversion, got %T", raw["views"]) + } + plugins, ok := views["plugins"].([]interface{}) + if !ok { + t.Fatalf("expected views.plugins to be a list, got %T", views["plugins"]) + } + if len(plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(plugins)) + } +} + +func TestConvertViewsFormat_AlreadyMap(t *testing.T) { + tr := NewLegacyConfigTransformer() + + raw := map[string]interface{}{ + "views": map[string]interface{}{ + "plugins": []interface{}{ + map[string]interface{}{"name": "Kanban"}, + }, + "actions": []interface{}{}, + }, + } + tr.ConvertViewsFormat(raw) + + // should be unchanged + views, ok := raw["views"].(map[string]interface{}) + if !ok { + t.Fatalf("expected views to remain a map, got %T", raw["views"]) + } + plugins, ok := views["plugins"].([]interface{}) + if !ok { + t.Fatalf("expected views.plugins to be a list, got %T", views["plugins"]) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } +} + +func TestConvertViewsFormat_NoViewsKey(t *testing.T) { + tr := NewLegacyConfigTransformer() + + raw := map[string]interface{}{ + "statuses": []interface{}{}, + } + tr.ConvertViewsFormat(raw) + + if _, ok := raw["views"]; ok { + t.Fatal("should not create views key when it doesn't exist") + } +} diff --git a/plugin/loader.go b/plugin/loader.go index 805bd9b..cd84139 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -13,33 +13,46 @@ import ( // WorkflowFile represents the YAML structure of a workflow.yaml file type WorkflowFile struct { - Plugins []pluginFileConfig `yaml:"views"` + Views viewsSectionConfig `yaml:"views"` } // loadPluginsFromFile loads plugins from a single workflow.yaml file. -// Returns the successfully loaded plugins and any validation errors encountered. -func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) { +// Returns the successfully loaded plugins, parsed global actions, and any validation errors encountered. +func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAction, []string) { data, err := os.ReadFile(path) if err != nil { slog.Warn("failed to read workflow.yaml", "path", path, "error", err) - return nil, []string{fmt.Sprintf("%s: %v", path, err)} + return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)} + } + + // pre-process raw YAML to handle legacy views format (list → map) + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + slog.Warn("failed to parse workflow.yaml", "path", path, "error", err) + return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)} + } + transformer := NewLegacyConfigTransformer() + transformer.ConvertViewsFormat(raw) + normalizedData, err := yaml.Marshal(raw) + if err != nil { + slog.Warn("failed to re-marshal workflow.yaml after legacy conversion", "path", path, "error", err) + return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)} } var wf WorkflowFile - if err := yaml.Unmarshal(data, &wf); err != nil { + if err := yaml.Unmarshal(normalizedData, &wf); err != nil { slog.Warn("failed to parse workflow.yaml", "path", path, "error", err) - return nil, []string{fmt.Sprintf("%s: %v", path, err)} + return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)} } - if len(wf.Plugins) == 0 { - return nil, nil + if len(wf.Views.Plugins) == 0 { + return nil, nil, nil } // convert legacy expressions to ruki before parsing - transformer := NewLegacyConfigTransformer() totalConverted := 0 - for i := range wf.Plugins { - totalConverted += transformer.ConvertPluginConfig(&wf.Plugins[i]) + for i := range wf.Views.Plugins { + totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i]) } if totalConverted > 0 { slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path) @@ -47,7 +60,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) { var plugins []Plugin var errs []string - for i, cfg := range wf.Plugins { + for i, cfg := range wf.Views.Plugins { if cfg.Name == "" { msg := fmt.Sprintf("%s: view at index %d has no name", path, i) slog.Warn("skipping plugin with no name in workflow.yaml", "index", i, "path", path) @@ -76,7 +89,21 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) { slog.Info("loaded plugin", "name", p.GetName(), "path", path, "key", keyName(pk, pr), "modifier", pm) } - return plugins, errs + // parse global plugin actions + var globalActions []PluginAction + if len(wf.Views.Actions) > 0 { + parser := ruki.NewParser(schema) + parsed, err := parsePluginActions(wf.Views.Actions, parser) + if err != nil { + slog.Warn("failed to parse global plugin actions", "path", path, "error", err) + errs = append(errs, fmt.Sprintf("%s: global actions: %v", path, err)) + } else { + globalActions = parsed + slog.Info("loaded global plugin actions", "count", len(globalActions), "path", path) + } + } + + return plugins, globalActions, errs } // mergePluginLists merges override plugins on top of base plugins. @@ -117,6 +144,7 @@ func mergePluginLists(base, overrides []Plugin) []Plugin { // LoadPlugins loads all plugins from workflow.yaml files: user config (base) + project config (overrides). // 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. +// Global actions are merged by key across files (later files override same-keyed globals from earlier files). // Returns an error when workflow files were found but no valid plugins could be loaded. func LoadPlugins(schema ruki.Schema) ([]Plugin, error) { files := config.FindWorkflowFiles() @@ -126,20 +154,26 @@ func LoadPlugins(schema ruki.Schema) ([]Plugin, error) { } var allErrors []string + var allGlobalActions []PluginAction // First file is the base (typically user config) - base, errs := loadPluginsFromFile(files[0], schema) + base, globalActions, errs := loadPluginsFromFile(files[0], schema) allErrors = append(allErrors, errs...) + allGlobalActions = append(allGlobalActions, globalActions...) // Remaining files are overrides, merged in order for _, path := range files[1:] { - overrides, errs := loadPluginsFromFile(path, schema) + overrides, moreGlobals, errs := loadPluginsFromFile(path, schema) allErrors = append(allErrors, errs...) if len(overrides) > 0 { base = mergePluginLists(base, overrides) } + allGlobalActions = mergeGlobalActions(allGlobalActions, moreGlobals) } + // merge global actions into each TikiPlugin + mergeGlobalActionsIntoPlugins(base, allGlobalActions) + if len(base) == 0 { if len(allErrors) > 0 { return nil, fmt.Errorf("no valid views loaded:\n %s\n\nTo install fresh defaults, remove the workflow file(s) and restart tiki:\n\n rm %s", @@ -154,6 +188,55 @@ func LoadPlugins(schema ruki.Schema) ([]Plugin, error) { return base, nil } +// mergeGlobalActions merges override global actions into base by key (rune). +// Overrides with the same rune replace the base action. +func mergeGlobalActions(base, overrides []PluginAction) []PluginAction { + if len(overrides) == 0 { + return base + } + byRune := make(map[rune]int, len(base)) + result := make([]PluginAction, len(base)) + copy(result, base) + for i, a := range result { + byRune[a.Rune] = i + } + for _, o := range overrides { + if idx, ok := byRune[o.Rune]; ok { + result[idx] = o + } else { + byRune[o.Rune] = len(result) + result = append(result, o) + } + } + return result +} + +// mergeGlobalActionsIntoPlugins appends global actions to each TikiPlugin. +// Per-plugin actions with the same rune take precedence over globals (global is skipped). +func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginAction) { + if len(globalActions) == 0 { + return + } + for _, p := range plugins { + tp, ok := p.(*TikiPlugin) + if !ok { + continue + } + localRunes := make(map[rune]bool, len(tp.Actions)) + for _, a := range tp.Actions { + localRunes[a.Rune] = true + } + for _, ga := range globalActions { + if localRunes[ga.Rune] { + slog.Info("per-plugin action overrides global action", + "plugin", tp.Name, "key", string(ga.Rune), "global_label", ga.Label) + continue + } + tp.Actions = append(tp.Actions, ga) + } + } +} + // DefaultPlugin returns the first plugin marked as default, or the first plugin // in the list if none are marked. The caller must ensure plugins is non-empty. func DefaultPlugin(plugins []Plugin) Plugin { diff --git a/plugin/loader_test.go b/plugin/loader_test.go index acf03fe..b444f7c 100644 --- a/plugin/loader_test.go +++ b/plugin/loader_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/boolean-maybe/tiki/ruki" ) func TestParsePluginConfig_FullyInline(t *testing.T) { @@ -140,7 +142,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) { t.Fatalf("Failed to write workflow.yaml: %v", err) } - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) if len(errs) != 0 { t.Fatalf("Expected no errors, got: %v", errs) } @@ -174,7 +176,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) { func TestLoadPluginsFromFile_NoFile(t *testing.T) { tmpDir := t.TempDir() - plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"), testSchema()) + 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)) } @@ -200,7 +202,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) { } // should load valid plugin and skip invalid one - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) if len(plugins) != 1 { t.Fatalf("Expected 1 valid plugin (invalid skipped), got %d", len(plugins)) } @@ -253,7 +255,7 @@ func TestLoadPluginsFromFile_LegacyConversion(t *testing.T) { t.Fatalf("write workflow.yaml: %v", err) } - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) if len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } @@ -298,7 +300,7 @@ func TestLoadPluginsFromFile_UnnamedPlugin(t *testing.T) { t.Fatalf("write workflow.yaml: %v", err) } - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) // unnamed plugin should be skipped, valid one should load if len(plugins) != 1 { t.Fatalf("expected 1 valid plugin, got %d", len(plugins)) @@ -318,7 +320,7 @@ func TestLoadPluginsFromFile_InvalidYAML(t *testing.T) { t.Fatalf("write workflow.yaml: %v", err) } - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) if plugins != nil { t.Error("expected nil plugins for invalid YAML") } @@ -336,7 +338,7 @@ func TestLoadPluginsFromFile_EmptyViews(t *testing.T) { t.Fatalf("write workflow.yaml: %v", err) } - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) if len(plugins) != 0 { t.Errorf("expected 0 plugins for empty views, got %d", len(plugins)) } @@ -364,7 +366,7 @@ func TestLoadPluginsFromFile_DokiConfigIndex(t *testing.T) { t.Fatalf("write workflow.yaml: %v", err) } - plugins, errs := loadPluginsFromFile(workflowPath, testSchema()) + plugins, _, errs := loadPluginsFromFile(workflowPath, testSchema()) if len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } @@ -416,6 +418,169 @@ func TestMergePluginLists(t *testing.T) { } } +func TestLoadPluginsFromFile_GlobalActions(t *testing.T) { + tmpDir := t.TempDir() + workflowContent := `views: + actions: + - key: "a" + label: "Assign to me" + action: update where id = id() set assignee=user() + plugins: + - name: Board + key: "B" + lanes: + - name: Todo + filter: select where status = "ready" +` + workflowPath := filepath.Join(tmpDir, "workflow.yaml") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("write workflow.yaml: %v", err) + } + + plugins, globalActions, errs := loadPluginsFromFile(workflowPath, testSchema()) + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if len(globalActions) != 1 { + t.Fatalf("expected 1 global action, got %d", len(globalActions)) + } + if globalActions[0].Rune != 'a' { + t.Errorf("expected rune 'a', got %q", globalActions[0].Rune) + } + if globalActions[0].Label != "Assign to me" { + t.Errorf("expected label 'Assign to me', got %q", globalActions[0].Label) + } +} + +func TestLoadPluginsFromFile_LegacyFormatWithGlobalActions(t *testing.T) { + tmpDir := t.TempDir() + // old list format — should still load plugins (global actions not possible in old format) + workflowContent := `views: + - name: Board + key: "B" + lanes: + - name: Todo + filter: select where status = "ready" +` + workflowPath := filepath.Join(tmpDir, "workflow.yaml") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("write workflow.yaml: %v", err) + } + + plugins, globalActions, errs := loadPluginsFromFile(workflowPath, testSchema()) + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if len(globalActions) != 0 { + t.Errorf("expected 0 global actions from legacy format, got %d", len(globalActions)) + } +} + +func TestMergeGlobalActions(t *testing.T) { + stmt := mustParseAction(t, `update where id = id() set status="ready"`) + + base := []PluginAction{ + {Rune: 'a', Label: "Assign", Action: stmt}, + {Rune: 'b', Label: "Board", Action: stmt}, + } + overrides := []PluginAction{ + {Rune: 'b', Label: "Board Override", Action: stmt}, + {Rune: 'c', Label: "Create", Action: stmt}, + } + + result := mergeGlobalActions(base, overrides) + if len(result) != 3 { + t.Fatalf("expected 3 actions, got %d", len(result)) + } + // 'a' unchanged, 'b' overridden, 'c' appended + if result[0].Label != "Assign" { + t.Errorf("expected 'Assign', got %q", result[0].Label) + } + if result[1].Label != "Board Override" { + t.Errorf("expected 'Board Override', got %q", result[1].Label) + } + if result[2].Label != "Create" { + t.Errorf("expected 'Create', got %q", result[2].Label) + } +} + +func TestMergeGlobalActions_EmptyOverrides(t *testing.T) { + stmt := mustParseAction(t, `update where id = id() set status="ready"`) + base := []PluginAction{{Rune: 'a', Label: "Assign", Action: stmt}} + result := mergeGlobalActions(base, nil) + if len(result) != 1 { + t.Fatalf("expected 1 action, got %d", len(result)) + } +} + +func TestMergeGlobalActionsIntoPlugins(t *testing.T) { + stmt := mustParseAction(t, `update where id = id() set status="ready"`) + + plugins := []Plugin{ + &TikiPlugin{ + BasePlugin: BasePlugin{Name: "Board"}, + Actions: []PluginAction{{Rune: 'b', Label: "Board action", Action: stmt}}, + }, + &TikiPlugin{ + BasePlugin: BasePlugin{Name: "Backlog"}, + Actions: nil, + }, + &DokiPlugin{ + BasePlugin: BasePlugin{Name: "Help"}, + }, + } + + globals := []PluginAction{ + {Rune: 'a', Label: "Assign", Action: stmt}, + {Rune: 'b', Label: "Global board", Action: stmt}, // conflicts with Board's 'b' + } + + mergeGlobalActionsIntoPlugins(plugins, globals) + + // Board: should have 'b' (local) + 'a' (global) — 'b' global skipped + board, ok := plugins[0].(*TikiPlugin) + if !ok { + t.Fatalf("Board: expected *TikiPlugin, got %T", plugins[0]) + } + if len(board.Actions) != 2 { + t.Fatalf("Board: expected 2 actions, got %d", len(board.Actions)) + } + if board.Actions[0].Label != "Board action" { + t.Errorf("Board: first action should be local 'Board action', got %q", board.Actions[0].Label) + } + if board.Actions[1].Label != "Assign" { + t.Errorf("Board: second action should be global 'Assign', got %q", board.Actions[1].Label) + } + + // Backlog: should have both globals ('a' and 'b') + backlog, ok := plugins[1].(*TikiPlugin) + if !ok { + t.Fatalf("Backlog: expected *TikiPlugin, got %T", plugins[1]) + } + if len(backlog.Actions) != 2 { + t.Fatalf("Backlog: expected 2 actions, got %d", len(backlog.Actions)) + } + + // Help (DokiPlugin): should have no actions (skipped) + // DokiPlugin has no Actions field — nothing to check +} + +func mustParseAction(t *testing.T, input string) *ruki.ValidatedStatement { + t.Helper() + parser := testParser() + stmt, err := parser.ParseAndValidateStatement(input, ruki.ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse ruki statement %q: %v", input, err) + } + return stmt +} + func TestDefaultPlugin_MultipleDefaults(t *testing.T) { plugins := []Plugin{ &TikiPlugin{BasePlugin: BasePlugin{Name: "A"}}, diff --git a/plugin/merger.go b/plugin/merger.go index d139e57..04a54f0 100644 --- a/plugin/merger.go +++ b/plugin/merger.go @@ -1,5 +1,15 @@ package plugin +// viewsSectionConfig represents the YAML structure of the views section. +// views: +// +// actions: [...] # global plugin actions +// plugins: [...] # plugin definitions +type viewsSectionConfig struct { + Actions []PluginActionConfig `yaml:"actions"` + Plugins []pluginFileConfig `yaml:"plugins"` +} + // pluginFileConfig represents the YAML structure of a plugin file type pluginFileConfig struct { Name string `yaml:"name"` diff --git a/plugin/parser.go b/plugin/parser.go index bdaa43c..308d54a 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -210,10 +210,6 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl if err != nil { return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err) } - if actionStmt.IsSelect() && !actionStmt.IsPipe() { - return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, DELETE, or a piped SELECT — not plain SELECT", i, cfg.Key) - } - actions = append(actions, PluginAction{ Rune: r, Label: cfg.Label, diff --git a/plugin/parser_test.go b/plugin/parser_test.go index 25e202e..3ac1a46 100644 --- a/plugin/parser_test.go +++ b/plugin/parser_test.go @@ -495,17 +495,20 @@ func TestParsePluginConfig_LaneActionMustBeUpdate(t *testing.T) { } } -func TestParsePluginActions_SelectRejectedAsAction(t *testing.T) { +func TestParsePluginActions_SelectAllowedAsAction(t *testing.T) { parser := testParser() configs := []PluginActionConfig{ {Key: "s", Label: "Search", Action: `select where status = "ready"`}, } - _, err := parsePluginActions(configs, parser) - if err == nil { - t.Fatal("expected error for SELECT as plugin action") + actions, err := parsePluginActions(configs, parser) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(err.Error(), "not plain SELECT") { - t.Errorf("expected 'not plain SELECT' error, got: %v", err) + if len(actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(actions)) + } + if !actions[0].Action.IsSelect() { + t.Error("expected action to be a SELECT statement") } } diff --git a/view/help/custom.md b/view/help/custom.md index c37984c..8796340 100644 --- a/view/help/custom.md +++ b/view/help/custom.md @@ -50,16 +50,17 @@ how Backlog is defined: ```yaml views: - - name: Backlog - key: "F3" - lanes: - - name: Backlog - columns: 4 - filter: select where status = "backlog" and type != "epic" order by priority, id - actions: - - key: "b" - label: "Add to board" - action: update where id = id() set status="ready" + plugins: + - name: Backlog + key: "F3" + lanes: + - name: Backlog + columns: 4 + filter: select where status = "backlog" and type != "epic" order by priority, id + actions: + - key: "b" + label: "Add to board" + action: update where id = id() set status="ready" ``` that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane. @@ -70,11 +71,12 @@ Likewise the documentation is just a plugin: ```yaml views: - - name: Docs - type: doki - fetcher: file - url: "index.md" - key: "F2" + plugins: + - name: Docs + type: doki + fetcher: file + url: "index.md" + key: "F2" ``` that translates to - show `index.md` file located under `.doc/doki` @@ -130,7 +132,24 @@ lanes: If no lanes specify width, all lanes are equally sized (the default behavior). -### Plugin actions +### Global plugin actions + +You can define actions under `views.actions` that are available in **all** tiki plugin views: + +```yaml +views: + actions: + - key: "a" + label: "Assign to me" + action: update where id = id() set assignee=user() + plugins: + - name: Kanban + ... +``` + +Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key, the per-plugin action takes precedence for that view. When multiple workflow files define `views.actions`, they merge by key — later files override same-keyed globals from earlier files. + +### Per-plugin actions In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active. @@ -148,14 +167,16 @@ actions: Each action has: - `key` - a single printable character used as the keyboard shortcut - `label` - description shown in the header -- `action` - a `ruki` `update` statement (same syntax as lane actions, see below) +- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`) When the shortcut key is pressed, the action is applied to the currently selected tiki. For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board. +`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki. + ### ruki expressions -Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements and actions use `update` statements. +Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored). #### Filter (select)