mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
global plugin actions
This commit is contained in:
parent
12a0ea86f3
commit
e275604e85
17 changed files with 791 additions and 236 deletions
|
|
@ -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"
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
113
plugin/loader.go
113
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 {
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue