mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
Compare commits
53 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bf8bd4d9 | ||
|
|
41883b510d | ||
|
|
4891de4163 | ||
|
|
8a68544ac9 | ||
|
|
f4e425b08b | ||
|
|
129f847622 | ||
|
|
7abddcd4c6 | ||
|
|
ef25f5ff98 | ||
|
|
48e796877e | ||
|
|
8838c29149 | ||
|
|
0312ad8fca | ||
|
|
232737940b | ||
|
|
f377bb54dd | ||
|
|
8cc1614724 | ||
|
|
025342c3af | ||
|
|
2175551e38 | ||
|
|
2a6c1201f8 | ||
|
|
936942d7b5 | ||
|
|
2a50feb6fb | ||
|
|
50a3d8ca20 | ||
|
|
1596bc9c39 | ||
|
|
db019108be | ||
|
|
7b7136ce5e | ||
|
|
80e7f1e510 | ||
|
|
f7ca8b44fa | ||
|
|
11b4ae2a7b | ||
|
|
529835df1c | ||
|
|
a63ad74845 | ||
|
|
47a57afc1c | ||
|
|
351d7817e3 | ||
|
|
56d3b64c22 | ||
|
|
6713b5b7c5 | ||
|
|
952c095372 | ||
|
|
73a95e2c5b | ||
|
|
7169f53c20 | ||
|
|
a226f433d4 | ||
|
|
3f86eb93ce | ||
|
|
3b33659338 | ||
|
|
80444f1848 | ||
|
|
31cfd46453 | ||
|
|
14d46d4802 | ||
|
|
ae3634da1b | ||
|
|
70d572b7a4 | ||
|
|
62468209bc | ||
|
|
692e559d5f | ||
|
|
b52e20d30f | ||
|
|
d2c28655bd | ||
|
|
aefb6a757d | ||
|
|
3c658f7332 | ||
|
|
e275604e85 | ||
|
|
12a0ea86f3 | ||
|
|
9ec6588f6b | ||
|
|
7f6f3654c3 |
172 changed files with 14762 additions and 2522 deletions
159
.doc/doki/doc/command-line.md
Normal file
159
.doc/doki/doc/command-line.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Command line options
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
tiki [command] [options]
|
||||
```
|
||||
|
||||
Running `tiki` with no arguments launches the TUI in an initialized project.
|
||||
|
||||
## Commands
|
||||
|
||||
### init
|
||||
|
||||
Initialize a tiki project in the current git repository. Creates the `.doc/tiki/` directory structure for task storage.
|
||||
|
||||
```bash
|
||||
tiki init
|
||||
```
|
||||
|
||||
### exec
|
||||
|
||||
Execute a [ruki](ruki/index.md) query and exit. Requires an initialized project.
|
||||
|
||||
```bash
|
||||
tiki exec '<ruki-statement>'
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
tiki exec 'select where status = "ready" order by priority'
|
||||
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
|
||||
```
|
||||
|
||||
### workflow
|
||||
|
||||
Manage workflow configuration files.
|
||||
|
||||
#### workflow reset
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
```bash
|
||||
tiki workflow reset [target] [--scope]
|
||||
```
|
||||
|
||||
**Targets** (omit to reset all three files):
|
||||
- `config` — config.yaml
|
||||
- `workflow` — workflow.yaml
|
||||
- `new` — new.md (task template)
|
||||
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
For `--global`, workflow.yaml and new.md are overwritten with embedded defaults. config.yaml is deleted (built-in defaults take over).
|
||||
|
||||
For `--local` and `--current`, files are deleted so the next tier in the [precedence chain](config.md#precedence-and-merging) takes effect.
|
||||
|
||||
```bash
|
||||
# restore all global config to defaults
|
||||
tiki workflow reset --global
|
||||
|
||||
# remove project workflow overrides (falls back to global)
|
||||
tiki workflow reset workflow --local
|
||||
|
||||
# remove cwd config override
|
||||
tiki workflow reset config --current
|
||||
```
|
||||
|
||||
#### workflow install
|
||||
|
||||
Install a named workflow from the tiki repository. Downloads `workflow.yaml` and `new.md` into the scope directory, overwriting any existing files.
|
||||
|
||||
```bash
|
||||
tiki workflow install <name> [--scope]
|
||||
```
|
||||
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
```bash
|
||||
# install the sprint workflow globally
|
||||
tiki workflow install sprint --global
|
||||
|
||||
# install the kanban workflow for the current project
|
||||
tiki workflow install kanban --local
|
||||
```
|
||||
|
||||
#### workflow describe
|
||||
|
||||
Fetch a workflow's description from the tiki repository and print it to stdout.
|
||||
Reads the top-level `description` field of the named workflow's `workflow.yaml`.
|
||||
Prints nothing and exits 0 if the workflow has no description field.
|
||||
|
||||
```bash
|
||||
tiki workflow describe <name>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# preview the todo workflow before installing it
|
||||
tiki workflow describe todo
|
||||
|
||||
# check what bug-tracker is for
|
||||
tiki workflow describe bug-tracker
|
||||
```
|
||||
|
||||
### demo
|
||||
|
||||
Clone the demo project and launch the TUI. If the `tiki-demo` directory already exists it is reused.
|
||||
|
||||
```bash
|
||||
tiki demo
|
||||
```
|
||||
|
||||
### sysinfo
|
||||
|
||||
Display system and terminal environment information useful for troubleshooting.
|
||||
|
||||
```bash
|
||||
tiki sysinfo
|
||||
```
|
||||
|
||||
## Markdown viewer
|
||||
|
||||
`tiki` doubles as a standalone markdown and image viewer. Pass a file path or URL as the first argument.
|
||||
|
||||
```bash
|
||||
tiki file.md
|
||||
tiki https://github.com/user/repo/blob/main/README.md
|
||||
tiki image.png
|
||||
echo "# Hello" | tiki -
|
||||
```
|
||||
|
||||
See [Markdown viewer](markdown-viewer.md) for navigation and keybindings.
|
||||
|
||||
## Piped input
|
||||
|
||||
When stdin is piped and no positional arguments are given, tiki creates a task from the input. The first line becomes the title; the rest becomes the description.
|
||||
|
||||
```bash
|
||||
echo "Fix the login bug" | tiki
|
||||
tiki < bug-report.md
|
||||
```
|
||||
|
||||
See [Quick capture](quick-capture.md) for more examples.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--help`, `-h` | Show usage information |
|
||||
| `--version`, `-v` | Show version, commit, and build date |
|
||||
| `--log-level <level>` | Set log level: `debug`, `info`, `warn`, `error` |
|
||||
|
|
@ -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,109 @@ 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()
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
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: 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"
|
||||
```
|
||||
249
.doc/doki/doc/custom-fields.md
Normal file
249
.doc/doki/doc/custom-fields.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Custom Fields
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Defining custom fields](#defining-custom-fields)
|
||||
- [Field types](#field-types)
|
||||
- [Enum fields](#enum-fields)
|
||||
- [Using custom fields in ruki](#using-custom-fields-in-ruki)
|
||||
- [Storage and frontmatter](#storage-and-frontmatter)
|
||||
- [Templates](#templates)
|
||||
- [Missing field behavior](#missing-field-behavior)
|
||||
|
||||
## Overview
|
||||
|
||||
Custom fields let you extend tikis with project-specific data beyond the built-in fields (title, status, priority, etc.). Define them in `workflow.yaml` and they become first-class citizens: usable in ruki queries, persisted in task frontmatter, and available across all views.
|
||||
|
||||
Use cases include:
|
||||
|
||||
- tracking a sprint or milestone name
|
||||
- adding an effort estimate or story-point alternative
|
||||
- flagging tasks with a boolean (e.g. `blocked`, `reviewed`)
|
||||
- recording a deadline timestamp with time-of-day precision
|
||||
- categorizing tasks with a constrained set of values (enum)
|
||||
- linking related tasks beyond `dependsOn`
|
||||
|
||||
## Defining custom fields
|
||||
|
||||
Add a `fields:` section to your `workflow.yaml`:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: sprint
|
||||
type: text
|
||||
- name: effort
|
||||
type: integer
|
||||
- name: blocked
|
||||
type: boolean
|
||||
- name: deadline
|
||||
type: datetime
|
||||
- name: category
|
||||
type: enum
|
||||
values:
|
||||
- frontend
|
||||
- backend
|
||||
- infra
|
||||
- docs
|
||||
- name: reviewers
|
||||
type: stringList
|
||||
- name: relatedTasks
|
||||
type: taskIdList
|
||||
```
|
||||
|
||||
Field names must not collide with built-in field names or ruki reserved keywords.
|
||||
|
||||
Custom fields follow the same merge semantics as other `workflow.yaml` sections. If the same field is defined identically in multiple files (user config, project config, cwd), the duplicate is silently accepted. If definitions conflict (different type or different enum values), loading fails with an error.
|
||||
|
||||
## Field types
|
||||
|
||||
| YAML type | Description | ruki type |
|
||||
|---------------|---------------------------------------|------------------|
|
||||
| `text` | free-form string | `string` |
|
||||
| `integer` | whole number | `int` |
|
||||
| `boolean` | true or false | `bool` |
|
||||
| `datetime` | timestamp (RFC3339 or YYYY-MM-DD) | `timestamp` |
|
||||
| `enum` | constrained string from `values` list | `enum` |
|
||||
| `stringList` | list of strings | `list<string>` |
|
||||
| `taskIdList` | list of tiki ID references | `list<ref>` |
|
||||
|
||||
## Enum fields
|
||||
|
||||
Enum fields require a `values:` list. Only those values are accepted when setting the field (case-insensitive matching, canonical casing preserved). Attempting to assign a value outside the list produces a validation error.
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values:
|
||||
- critical
|
||||
- major
|
||||
- minor
|
||||
- trivial
|
||||
```
|
||||
|
||||
Enum domains are field-scoped: two different enum fields maintain independent value sets. Cross-field enum assignment (e.g. `set severity = category`) is rejected even if the values happen to overlap.
|
||||
|
||||
Non-enum fields must not include a `values:` list.
|
||||
|
||||
## Using custom fields in ruki
|
||||
|
||||
Custom fields work the same as built-in fields in all ruki contexts: `select`, `update`, `create`, `order by`, `where`, and triggers.
|
||||
|
||||
### Filtering with select where
|
||||
|
||||
```sql
|
||||
-- find blocked tasks
|
||||
select where blocked = true
|
||||
|
||||
-- find tasks in a specific sprint
|
||||
select where sprint = "sprint-7"
|
||||
|
||||
-- find critical tasks in the frontend category
|
||||
select where severity = "critical" and category = "frontend"
|
||||
|
||||
-- find tasks with high effort
|
||||
select where effort > 5
|
||||
|
||||
-- find tasks with a deadline before a date
|
||||
select where deadline < 2026-05-01
|
||||
```
|
||||
|
||||
### Updating with update set
|
||||
|
||||
```sql
|
||||
-- assign a sprint
|
||||
update where id = id() set sprint="sprint-7"
|
||||
|
||||
-- mark as blocked
|
||||
update where id = id() set blocked=true
|
||||
|
||||
-- set category and severity
|
||||
update where id = id() set category="backend" severity="major"
|
||||
|
||||
-- clear a custom field (set to empty)
|
||||
update where id = id() set sprint=empty
|
||||
|
||||
-- add a reviewer
|
||||
update where id = id() set reviewers=reviewers + ["alice"]
|
||||
```
|
||||
|
||||
### Ordering with order by
|
||||
|
||||
```sql
|
||||
-- sort by effort descending
|
||||
select where status = "ready" order by effort desc
|
||||
|
||||
-- sort by category, then priority
|
||||
select where status = "backlog" order by category, priority
|
||||
|
||||
-- sort by deadline
|
||||
select where deadline is not empty order by deadline
|
||||
```
|
||||
|
||||
### Creating with custom field defaults
|
||||
|
||||
```sql
|
||||
-- create a task with custom fields
|
||||
create title="New feature" category="frontend" effort=3
|
||||
|
||||
-- create with enum and boolean
|
||||
create title="Fix crash" severity="critical" blocked=false
|
||||
```
|
||||
|
||||
### Plugin filters and actions
|
||||
|
||||
Custom fields integrate into plugin definitions in `workflow.yaml`:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
plugins:
|
||||
- name: Sprint Board
|
||||
key: "F5"
|
||||
lanes:
|
||||
- name: Current Sprint
|
||||
filter: select where sprint = "sprint-7" and status != "done" order by effort desc
|
||||
action: update where id = id() set sprint="sprint-7"
|
||||
- name: Next Sprint
|
||||
filter: select where sprint = "sprint-8" order by priority
|
||||
action: update where id = id() set sprint="sprint-8"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Mark blocked"
|
||||
action: update where id = id() set blocked=true
|
||||
```
|
||||
|
||||
## Storage and frontmatter
|
||||
|
||||
Custom fields are stored in task frontmatter alongside built-in fields:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Implement search
|
||||
type: story
|
||||
status: in_progress
|
||||
priority: 2
|
||||
points: 3
|
||||
tags:
|
||||
- search
|
||||
sprint: sprint-7
|
||||
blocked: false
|
||||
category: backend
|
||||
effort: 5
|
||||
deadline: 2026-05-15T17:00:00Z
|
||||
reviewers:
|
||||
- alice
|
||||
- bob
|
||||
relatedTasks:
|
||||
- TIKI-ABC123
|
||||
---
|
||||
Search implementation details...
|
||||
```
|
||||
|
||||
Custom fields appear after the built-in fields, sorted alphabetically by name.
|
||||
|
||||
On load, unknown frontmatter keys that are not registered custom fields are preserved as-is and survive save-load round-trips. This allows workflow changes without losing data — see [Schema evolution](ruki/custom-fields-reference.md#schema-evolution-and-stale-data) for details.
|
||||
|
||||
## Templates
|
||||
|
||||
Custom fields can have defaults in `new.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: story
|
||||
status: backlog
|
||||
priority: 3
|
||||
points: 1
|
||||
sprint: sprint-7
|
||||
blocked: false
|
||||
category: backend
|
||||
---
|
||||
```
|
||||
|
||||
Custom field values in the template are validated against their type definitions and enum constraints, the same as in task files.
|
||||
|
||||
## Missing field behavior
|
||||
|
||||
When a custom field is not set on a task, ruki returns the typed zero value for that field's type:
|
||||
|
||||
| Field type | Zero value |
|
||||
|---------------|--------------------|
|
||||
| `text` | `""` (empty string)|
|
||||
| `integer` | `0` |
|
||||
| `boolean` | `false` |
|
||||
| `datetime` | zero time |
|
||||
| `enum` | `""` (empty string)|
|
||||
| `stringList` | `[]` (empty list) |
|
||||
| `taskIdList` | `[]` (empty list) |
|
||||
|
||||
This means `select where blocked = false` matches both tasks explicitly set to `false` and tasks that never had the `blocked` field set. Use `is empty` / `is not empty` to distinguish:
|
||||
|
||||
```sql
|
||||
-- tasks that explicitly have blocked set (to any value)
|
||||
select where blocked is not empty
|
||||
|
||||
-- tasks where blocked was never set
|
||||
select where blocked is empty
|
||||
```
|
||||
|
||||
Note: for boolean and integer fields, the zero value (`false`, `0`) is also the `empty` value. An explicitly stored `false` and a missing boolean field are indistinguishable at query time. If you need the distinction, consider using an enum field with explicit values (e.g. `yes` / `no` / not set via `empty`).
|
||||
125
.doc/doki/doc/custom-status-type.md
Normal file
125
.doc/doki/doc/custom-status-type.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Custom Statuses and Types
|
||||
|
||||
Statuses and types are user-configurable via `workflow.yaml`.
|
||||
Both follow the same structural rules with a few differences noted below.
|
||||
|
||||
## Configuration
|
||||
|
||||
### YAML Shape
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
```
|
||||
|
||||
### Shared Rules
|
||||
|
||||
These rules apply identically to both `statuses:` and `types:`:
|
||||
|
||||
| Rule | Detail |
|
||||
|---|---|
|
||||
| Canonical keys | Keys must already be in canonical form. Non-canonical keys are rejected with a suggested canonical form. |
|
||||
| Label defaults to key | When `label` is omitted, the key is used as the label. |
|
||||
| Empty labels rejected | Explicitly empty or whitespace-only labels are invalid. |
|
||||
| Emoji trimmed | Leading/trailing whitespace is stripped from emoji values. |
|
||||
| Unique display strings | Each entry must produce a unique `"Label Emoji"` display. Duplicates are rejected. |
|
||||
| At least one entry | An empty list is invalid. |
|
||||
| Duplicate keys rejected | Two entries with the same canonical key are invalid. |
|
||||
| Unknown keys rejected | Only documented keys are allowed in each entry. |
|
||||
|
||||
### Status-Only Keys
|
||||
|
||||
Statuses support additional boolean flags that types do not:
|
||||
|
||||
| Key | Required | Description |
|
||||
|---|---|---|
|
||||
| `active` | no | Marks a status as active (in-progress work). |
|
||||
| `default` | exactly one | The status assigned to newly created tasks. |
|
||||
| `done` | exactly one | The terminal status representing completion. |
|
||||
|
||||
Valid keys in a status entry: `key`, `label`, `emoji`, `active`, `default`, `done`.
|
||||
|
||||
### Type-Only Behavior
|
||||
|
||||
Types have no boolean flags. The first configured type is used as the creation default.
|
||||
|
||||
Valid keys in a type entry: `key`, `label`, `emoji`.
|
||||
|
||||
### Key Normalization
|
||||
|
||||
Status and type keys use different normalization rules:
|
||||
|
||||
- **Status keys** use camelCase. Splits on `_`, `-`, ` `, and camelCase boundaries, then reassembles as camelCase.
|
||||
Examples: `"in_progress"` -> `"inProgress"`, `"In Progress"` -> `"inProgress"`.
|
||||
|
||||
- **Type keys** are lowercased with all separators stripped.
|
||||
Examples: `"My-Type"` -> `"mytype"`, `"some_thing"` -> `"something"`.
|
||||
|
||||
Keys in `workflow.yaml` must already be in their canonical form. Input normalization (from user queries, ruki expressions, etc.) still applies at lookup time.
|
||||
|
||||
### Inheritance and Override
|
||||
|
||||
- A section (`statuses:` or `types:`) absent from a workflow file means "no opinion" -- it does not override the inherited value.
|
||||
- A non-empty section fully replaces inherited/built-in entries. No merging across files.
|
||||
- The last file (most specific location) with the section present wins.
|
||||
- If no file defines `types:`, built-in defaults are used (`story`, `bug`, `spike`, `epic`).
|
||||
- Statuses have no built-in fallback -- at least one workflow file must define `statuses:`.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
### Invalid Configuration
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Empty list | Error |
|
||||
| Non-canonical key | Error with suggested canonical form |
|
||||
| Empty/whitespace label | Error |
|
||||
| Duplicate display string | Error |
|
||||
| Unknown key in entry | Error |
|
||||
| Missing `default: true` (statuses) | Error |
|
||||
| Missing `done: true` (statuses) | Error |
|
||||
| Multiple `default: true` (statuses) | Error |
|
||||
| Multiple `done: true` (statuses) | Error |
|
||||
|
||||
### Invalid Saved Tasks
|
||||
|
||||
- A tiki with a missing or unknown `type` fails to load and is skipped.
|
||||
- On single-task reload (`ReloadTask`), an invalid file causes the task to be removed from memory.
|
||||
|
||||
### Invalid Templates
|
||||
|
||||
- Missing `type` in a template defaults to the first configured type.
|
||||
- Invalid non-empty `type` in a template is a hard error; creation is aborted.
|
||||
|
||||
### Sample Tasks at Init
|
||||
|
||||
- Each embedded sample is validated against the active registries before writing.
|
||||
- Incompatible samples are silently skipped.
|
||||
- `tiki init` offers a "Create sample tasks" checkbox (default: enabled).
|
||||
|
||||
### Cross-Reference Errors
|
||||
|
||||
If a `types:` override removes type keys still referenced by inherited views, actions, or triggers, startup fails with a configuration error. There is no silent view-skipping or automatic remapping.
|
||||
|
||||
## Pre-Init Rules
|
||||
|
||||
Calling type or status helpers (`task.ParseType()`, `task.AllTypes()`, `task.DefaultType()`, `task.ParseStatus()`, etc.) before `config.LoadWorkflowRegistries()` is a programmer error and panics.
|
||||
|
|
@ -4,10 +4,23 @@ tiki is highly customizable. `workflow.yaml` lets you define your workflow statu
|
|||
how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through,
|
||||
while plugins control what you see and how you interact with your work. This section covers both.
|
||||
|
||||
## Description
|
||||
|
||||
An optional top-level `description:` field in `workflow.yaml` describes what
|
||||
the workflow is for. It supports multi-line text via YAML's block scalar (`|`)
|
||||
and is used by `tiki workflow describe <name>` to preview a workflow before
|
||||
installing it.
|
||||
|
||||
```yaml
|
||||
description: |
|
||||
Release workflow. Coordinate feature rollout through
|
||||
Planned → Building → Staging → Canary → Released.
|
||||
```
|
||||
|
||||
## Statuses
|
||||
|
||||
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define
|
||||
its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
|
||||
its statuses here — there is no hardcoded fallback. See [Custom statuses and types](custom-status-type.md). The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
|
|
@ -19,7 +32,7 @@ statuses:
|
|||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
|
|
@ -34,15 +47,43 @@ statuses:
|
|||
```
|
||||
|
||||
Each status has:
|
||||
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI
|
||||
- `key` — canonical camelCase identifier. Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI (defaults to key when omitted)
|
||||
- `emoji` — emoji shown alongside the label
|
||||
- `active` — marks the status as "active work" (used for activity tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one status should have this)
|
||||
- `done` — marks the status as "completed" (used for completion tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one required)
|
||||
- `done` — marks the status as "completed" (exactly one required)
|
||||
|
||||
You can customize these to match your team's workflow. All filters and actions in view definitions (see below) must reference valid status keys.
|
||||
|
||||
## Types
|
||||
|
||||
Task types are defined in `workflow.yaml` under the `types:` key. If omitted, built-in defaults are used.
|
||||
See [Custom statuses and types](custom-status-type.md) for the full validation and inheritance rules. The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
- key: spike
|
||||
label: Spike
|
||||
emoji: "🔍"
|
||||
- key: epic
|
||||
label: Epic
|
||||
emoji: "🗂️"
|
||||
```
|
||||
|
||||
Each type has:
|
||||
- `key` — canonical lowercase identifier. Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI (defaults to key when omitted)
|
||||
- `emoji` — emoji shown alongside the label
|
||||
|
||||
The first configured type is used as the default for new tikis.
|
||||
|
||||
## Task Template
|
||||
|
||||
When you create a new tiki — whether in the TUI or command line — field defaults come from a template file.
|
||||
|
|
@ -53,8 +94,7 @@ The file uses YAML frontmatter for field defaults
|
|||
|
||||
```markdown
|
||||
---
|
||||
type: story
|
||||
status: backlog
|
||||
title:
|
||||
points: 1
|
||||
priority: 3
|
||||
tags:
|
||||
|
|
@ -62,6 +102,8 @@ tags:
|
|||
---
|
||||
```
|
||||
|
||||
Type and status are omitted but can be added, otherwise they default to the first configured type and the status marked `default: true`.
|
||||
|
||||
## Plugins
|
||||
|
||||
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
|
||||
|
|
@ -69,17 +111,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 +133,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 +195,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.
|
||||
|
|
@ -168,15 +233,75 @@ 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)
|
||||
- `label` - description shown in the header and action palette
|
||||
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
|
||||
- `hot` - (optional) controls header visibility. `hot: true` shows the action in the header, `hot: false` hides it. When absent, actions default to visible in the header. This does not affect the action palette — all actions are always discoverable via `?` regardless of the `hot` setting
|
||||
- `input` - (optional) declares that the action prompts for user input before executing. The value is the scalar type of the input: `string`, `int`, `bool`, `date`, `timestamp`, or `duration`. The action's `ruki` statement must use `input()` to reference the value
|
||||
|
||||
Example — keeping a verbose action out of the header but still accessible from the palette:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "x"
|
||||
label: "Archive and notify"
|
||||
action: update where id = id() set status="done"
|
||||
hot: false
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Input-backed actions
|
||||
|
||||
Actions with `input:` prompt the user for a value before executing. When the action key is pressed, a modal input box opens with the action label as the prompt. The user types a value and presses Enter to execute, or Esc to cancel.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee = input()
|
||||
input: string
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags = tags + [input()]
|
||||
input: string
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags = tags - [input()]
|
||||
input: string
|
||||
- key: "p"
|
||||
label: "Set points"
|
||||
action: update where id = id() set points = input()
|
||||
input: int
|
||||
- key: "D"
|
||||
label: "Set due date"
|
||||
action: update where id = id() set due = input()
|
||||
input: date
|
||||
```
|
||||
|
||||
The input box is modal while editing — other actions are blocked until Enter or Esc. If the entered value is invalid for the declared type (e.g. non-numeric text for `int`), an error appears in the statusline and the prompt stays open for correction.
|
||||
|
||||
Supported `input:` types: `string`, `int`, `bool`, `date` (YYYY-MM-DD), `timestamp` (RFC3339 or YYYY-MM-DD), `duration` (e.g. `2day`, `1week`).
|
||||
|
||||
Validation rules:
|
||||
- An action with `input:` must use `input()` in its `ruki` statement
|
||||
- An action using `input()` must declare `input:` — otherwise the workflow fails to load
|
||||
- `input()` may only appear once per action
|
||||
|
||||
### Search and input box interaction
|
||||
|
||||
The input box serves both search and action-input, with explicit mode tracking:
|
||||
|
||||
- **Search editing**: pressing `/` opens the input box focused for typing. Enter with text applies the search and transitions to **search passive** mode. Enter on empty text is a no-op. Esc clears search and closes the box.
|
||||
- **Search passive**: the search box remains visible as a non-editable indicator showing the active query, while normal task navigation and actions are re-enabled. Pressing `/` again is blocked — dismiss the active search with Esc first, then open a new search. Esc clears the search results and closes the box.
|
||||
- **Action input**: pressing an input-backed action key opens a modal prompt. If search was passive, the prompt temporarily replaces the search indicator. Valid Enter executes the action and restores the passive search indicator (or closes if no prior search). Esc cancels and likewise restores passive search. Invalid Enter keeps the prompt open for correction.
|
||||
- **Modal blocking**: while search editing or action input is active, all other plugin actions and keyboard shortcuts are blocked. The action palette cannot open while the input box is editing.
|
||||
|
||||
### 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)
|
||||
|
||||
|
|
@ -215,7 +340,7 @@ update where id = id() set assignee=user()
|
|||
|
||||
- `id` - task identifier (e.g., "TIKI-M7N2XK")
|
||||
- `title` - task title text
|
||||
- `type` - task type: "story", "bug", "spike", or "epic"
|
||||
- `type` - task type (must match a key defined in `workflow.yaml` types)
|
||||
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - assigned user
|
||||
- `priority` - numeric priority value (1-5)
|
||||
|
|
@ -246,6 +371,7 @@ update where id = id() set assignee=user()
|
|||
- `user()` — current user
|
||||
- `now()` — current timestamp
|
||||
- `id()` — currently selected tiki (in plugin context)
|
||||
- `input()` — user-supplied value (in actions with `input:` declaration)
|
||||
- `count(select where ...)` — count matching tikis
|
||||
|
||||
For the full language reference, see the [ruki documentation](ruki/index.md).
|
||||
For the full language reference, see the [ruki documentation](ruki/index.md).
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
- [Assign to me](#assign-to-me--plugin-action)
|
||||
- [Add tag to task](#add-tag-to-task--plugin-action)
|
||||
- [Custom status + reject action](#custom-status--reject-action)
|
||||
- [Implement with Claude Code](#implement-with-claude-code--pipe-action)
|
||||
- [Search all tikis](#search-all-tikis--single-lane-plugin)
|
||||
- [Quick assign](#quick-assign--lane-based-assignment)
|
||||
- [Stale task detection](#stale-task-detection--time-trigger--plugin)
|
||||
|
|
@ -10,19 +11,25 @@
|
|||
- [Recent ideas](#recent-ideas--good-or-trash)
|
||||
- [Auto-delete stale tasks](#auto-delete-stale-tasks--time-trigger)
|
||||
- [Priority triage](#priority-triage--five-lane-plugin)
|
||||
- [Sprint board](#sprint-board--custom-enum-lanes)
|
||||
- [Severity triage](#severity-triage--custom-enum-filter--action)
|
||||
- [Subtasks in epic](#subtasks-in-epic--custom-taskidlist--quantifier-trigger)
|
||||
- [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.
|
||||
|
|
@ -58,6 +65,19 @@ statuses:
|
|||
action: update where id = id() set status="rejected"
|
||||
```
|
||||
|
||||
## Implement with Claude Code — pipe action
|
||||
|
||||
Shortcut key that pipes the selected task's title and description to Claude Code for implementation.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "i"
|
||||
label: "Implement"
|
||||
action: >
|
||||
select title, description where id = id()
|
||||
| run("claude -p 'Implement this: $1. Details: $2'")
|
||||
```
|
||||
|
||||
## Search all tikis — single-lane plugin
|
||||
|
||||
A plugin with one unfiltered lane shows every task. Press `/` to search across all of them.
|
||||
|
|
@ -182,6 +202,109 @@ One lane per priority level. Moving a task between lanes reassigns its priority.
|
|||
action: update where id = id() set priority=5
|
||||
```
|
||||
|
||||
## Sprint board — custom enum lanes
|
||||
|
||||
Uses a custom `sprint` enum field. Lanes per sprint; moving a task between lanes reassigns it. The third lane catches unplanned backlog tasks.
|
||||
|
||||
Requires:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: sprint
|
||||
type: enum
|
||||
values: [sprint-7, sprint-8, sprint-9]
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Sprint Board
|
||||
key: "F9"
|
||||
lanes:
|
||||
- name: Current Sprint
|
||||
filter: select where sprint = "sprint-7" and status != "done" order by priority
|
||||
action: update where id = id() set sprint="sprint-7"
|
||||
- name: Next Sprint
|
||||
filter: select where sprint = "sprint-8" order by priority
|
||||
action: update where id = id() set sprint="sprint-8"
|
||||
- name: Unplanned
|
||||
filter: select where sprint is empty and status = "backlog" order by priority
|
||||
action: update where id = id() set sprint=empty
|
||||
```
|
||||
|
||||
## Severity triage — custom enum filter + action
|
||||
|
||||
Lanes per severity level. The last lane combines two values with `or`. A per-plugin action lets you mark a task as trivial without moving it.
|
||||
|
||||
Requires:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values: [critical, major, minor, trivial]
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Severity
|
||||
key: "F10"
|
||||
lanes:
|
||||
- name: Critical
|
||||
filter: select where severity = "critical" order by updatedAt desc
|
||||
action: update where id = id() set severity="critical"
|
||||
- name: Major
|
||||
filter: select where severity = "major" order by updatedAt desc
|
||||
action: update where id = id() set severity="major"
|
||||
- name: Minor & Trivial
|
||||
columns: 2
|
||||
filter: >
|
||||
select where severity = "minor" or severity = "trivial"
|
||||
order by severity, priority
|
||||
action: update where id = id() set severity="minor"
|
||||
actions:
|
||||
- key: "t"
|
||||
label: "Trivial"
|
||||
action: update where id = id() set severity="trivial"
|
||||
```
|
||||
|
||||
## Subtasks in epic — custom taskIdList + quantifier trigger
|
||||
|
||||
A `subtasks` field on parent tasks tracks their children (inverse of `dependsOn`). A trigger auto-completes the parent when every subtask is done. The plugin shows open vs. completed parents.
|
||||
|
||||
Requires:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: subtasks
|
||||
type: taskIdList
|
||||
```
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: close parent when all subtasks are done
|
||||
ruki: >
|
||||
every 5min
|
||||
update where subtasks is not empty
|
||||
and status != "done"
|
||||
and all subtasks where status = "done"
|
||||
set status="done"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Epics
|
||||
key: "F11"
|
||||
lanes:
|
||||
- name: In Progress
|
||||
filter: >
|
||||
select where subtasks is not empty
|
||||
and status != "done"
|
||||
order by priority
|
||||
- name: Completed
|
||||
columns: 1
|
||||
filter: >
|
||||
select where subtasks is not empty
|
||||
and status = "done"
|
||||
order by updatedAt desc
|
||||
```
|
||||
|
||||
## By topic — tag-based lanes
|
||||
|
||||
Split tasks into lanes by tag. Useful for viewing work across domains at a glance.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
# Documentation
|
||||
- [Quick start](quick-start.md)
|
||||
- [Configuration](config.md)
|
||||
- [Installation](install.md)
|
||||
- [Configuration](config.md)
|
||||
- [Command line options](command-line.md)
|
||||
- [Markdown viewer](markdown-viewer.md)
|
||||
- [Image support](image-requirements.md)
|
||||
- [Custom fields](custom-fields.md)
|
||||
- [Custom statuses and types](custom-status-type.md)
|
||||
- [Customization](customization.md)
|
||||
- [Themes](themes.md)
|
||||
- [ruki](ruki/index.md)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ cd /tmp && tiki demo
|
|||
```
|
||||
|
||||
Move your tiki around the board with `Shift ←/Shift →`.
|
||||
Make sure to press `?` for help.
|
||||
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
|
||||
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
|
||||
|
||||
## AI skills
|
||||
|
|
@ -56,7 +56,7 @@ Store your notes in remotes!
|
|||
|
||||
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
|
||||
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
|
||||
Read more by pressing `?` for help
|
||||
Press `?` to open the Action Palette and discover all available actions
|
||||
|
||||
# AI skills
|
||||
|
||||
|
|
|
|||
175
.doc/doki/doc/ruki/custom-fields-reference.md
Normal file
175
.doc/doki/doc/ruki/custom-fields-reference.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Custom Fields Reference
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Registration and loading](#registration-and-loading)
|
||||
- [Naming constraints](#naming-constraints)
|
||||
- [Type coercion rules](#type-coercion-rules)
|
||||
- [Enum domain isolation](#enum-domain-isolation)
|
||||
- [Validation rules](#validation-rules)
|
||||
- [Persistence and round-trip behavior](#persistence-and-round-trip-behavior)
|
||||
- [Schema evolution and stale data](#schema-evolution-and-stale-data)
|
||||
- [Template defaults](#template-defaults)
|
||||
- [Query behavior](#query-behavior)
|
||||
- [Missing-field semantics](#missing-field-semantics)
|
||||
|
||||
## Overview
|
||||
|
||||
Custom fields extend tiki's built-in field catalog with user-defined fields declared in `workflow.yaml`. This reference covers the precise rules for how custom fields are loaded, validated, persisted, and queried — the behavioral contract behind the [Custom Fields](../custom-fields.md) user guide.
|
||||
|
||||
## Registration and loading
|
||||
|
||||
Custom field definitions are loaded from all `workflow.yaml` files across the three-tier search path (user config, project config, working directory). Files that define `fields:` but no `views:` still contribute field definitions.
|
||||
|
||||
Definitions from multiple files are merged by name:
|
||||
|
||||
- **identical redefinition** (same name, same type, same enum values in same order): silently accepted
|
||||
- **conflicting redefinition** (same name, different type or values): fatal error naming both source files
|
||||
|
||||
After merging, fields are sorted by name for deterministic ordering and registered into the field catalog alongside built-in fields. Once registered, custom fields are available for ruki parsing, validation, and execution.
|
||||
|
||||
Registration happens during bootstrap before any task or template loading occurs.
|
||||
|
||||
## Naming constraints
|
||||
|
||||
Field names must:
|
||||
|
||||
- match the ruki identifier pattern (letters, digits, underscores; must start with a letter)
|
||||
- not collide with ruki reserved keywords (`select`, `update`, `where`, `and`, `or`, `not`, `in`, `is`, `empty`, `order`, `by`, `asc`, `desc`, `set`, `create`, `delete`, `limit`, etc.)
|
||||
- not collide with built-in field names, case-insensitively (`title`, `status`, `priority`, `tags`, `dependsOn`, etc.)
|
||||
- not be `true` or `false` (reserved boolean literals)
|
||||
|
||||
Collision checks are case-insensitive: `Status` and `STATUS` both collide with the built-in `status`.
|
||||
|
||||
## Type coercion rules
|
||||
|
||||
When custom field values are read from frontmatter YAML, they are coerced to the expected type:
|
||||
|
||||
| Field type | Accepted YAML values | Coercion behavior |
|
||||
|---------------|-----------------------------------------------|--------------------------------------------------|
|
||||
| `text` | string | pass-through |
|
||||
| `enum` | string | case-insensitive match against allowed values; stored in canonical casing |
|
||||
| `integer` | integer or decimal number | integer pass-through; decimal accepted only if it represents a whole number (e.g. `3.0` → `3`, but `1.5` → error) |
|
||||
| `boolean` | `true` / `false` | pass-through |
|
||||
| `datetime` | timestamp or date string | native YAML timestamps pass through; strings parsed as RFC3339, with fallback to `YYYY-MM-DD` |
|
||||
| `stringList` | YAML list of strings | each element must be a string |
|
||||
| `taskIdList` | YAML list of strings | each element coerced to uppercase, whitespace trimmed, empty entries dropped |
|
||||
|
||||
## Enum domain isolation
|
||||
|
||||
Each enum field maintains its own independent set of allowed values. Two enum fields never share a domain, even if their values happen to overlap.
|
||||
|
||||
This isolation is enforced at three levels:
|
||||
|
||||
- **assignment**: `set severity = category` is rejected even if both are enum fields
|
||||
- **comparison**: `where severity = category` is rejected (comparing different enum domains)
|
||||
- **in-expression**: string literals in an `in` list are validated against the specific enum field's allowed values
|
||||
|
||||
Enum comparison is case-insensitive: `category = "Backend"` matches a stored `"backend"`.
|
||||
|
||||
## Validation rules
|
||||
|
||||
Custom fields follow the same validation pipeline as built-in fields:
|
||||
|
||||
- **type compatibility**: assignments and comparisons are type-checked (e.g. you cannot assign a string to an integer field, or compare an enum field with an integer literal)
|
||||
- **enum value validation**: string literals assigned to or compared against an enum field must be in that field's allowed values
|
||||
- **ordering**: custom fields of orderable types (`text`, `integer`, `boolean`, `datetime`, `enum`) can appear in `order by` clauses; list types (`stringList`, `taskIdList`) are not orderable
|
||||
|
||||
## Persistence and round-trip behavior
|
||||
|
||||
Custom fields are stored in task file frontmatter alongside built-in fields. When a task is saved:
|
||||
|
||||
- custom fields appear after built-in fields, sorted alphabetically by name
|
||||
- values that look ambiguous in YAML (e.g. a text field containing `"true"`, `"42"`, or `"2026-05-15"`) are quoted to prevent YAML type coercion from corrupting them on reload
|
||||
|
||||
A save-then-load cycle preserves custom field values exactly. This holds as long as:
|
||||
|
||||
- the field definitions in `workflow.yaml` have not changed between save and load
|
||||
- enum values use canonical casing (enforced automatically by coercion)
|
||||
- timestamps round-trip through RFC3339 format
|
||||
|
||||
## Schema evolution and stale data
|
||||
|
||||
When `workflow.yaml` changes — fields renamed, enum values added or removed, field types changed — existing task files may contain values that no longer match the current schema. tiki handles this gracefully:
|
||||
|
||||
### Removed fields
|
||||
|
||||
If a frontmatter key no longer matches any registered custom field, it is preserved as an **unknown field**. Unknown fields survive load-save round-trips: they are written back to the file exactly as found. This allows manual cleanup or re-registration without data loss.
|
||||
|
||||
### Stale enum values
|
||||
|
||||
If a task file contains an enum value that is no longer in the field's allowed values list (e.g. `severity: critical` after `critical` was removed from the enum), the value is **demoted to an unknown field** with a warning. The task still loads and remains visible in views. The stale value is preserved in the file for repair.
|
||||
|
||||
### Type mismatches
|
||||
|
||||
If a value cannot be coerced to the field's current type (e.g. a text value `"not_a_number"` in a field that was changed to `integer`), the same demotion-to-unknown behavior applies: the task loads, the value is preserved, a warning is logged.
|
||||
|
||||
### General principle
|
||||
|
||||
tiki reads leniently and writes strictly. On load, unrecognized or incompatible values are preserved rather than rejected. On save, values are validated against the current schema.
|
||||
|
||||
## Template defaults
|
||||
|
||||
Custom fields in `new.md` templates follow the same coercion and validation rules as task files. If a template contains a value that cannot be coerced (e.g. a type mismatch), the invalid field is dropped with a warning and the template otherwise loads normally.
|
||||
|
||||
Template custom field values are copied into new tasks created via `create` statements or the new-task UI flow.
|
||||
|
||||
## Query behavior
|
||||
|
||||
Custom fields behave identically to built-in fields in ruki queries:
|
||||
|
||||
- usable in `where`, `order by`, `set`, `create`, and `select` field lists
|
||||
- support `is empty` / `is not empty` checks
|
||||
- support `in` / `not in` for list membership
|
||||
- list-type fields support `+` (append) and `-` (remove) operations
|
||||
- quantifiers (`any ... where`, `all ... where`) work on custom `taskIdList` fields
|
||||
|
||||
### Unset list fields
|
||||
|
||||
Unset custom list fields (`stringList`, `taskIdList`) behave the same as empty built-in list fields (`tags`, `dependsOn`). They return an empty list, not an absent value. This means:
|
||||
|
||||
- `"x" in labels` evaluates to `false` (not an error)
|
||||
- `labels + ["new"]` produces `["new"]` (not an error)
|
||||
- `labels is empty` evaluates to `true`
|
||||
|
||||
## Missing-field semantics
|
||||
|
||||
When a custom field has never been set on a task, ruki returns the typed zero value:
|
||||
|
||||
| Field type | Zero value |
|
||||
|---------------|--------------------|
|
||||
| `text` | `""` (empty string)|
|
||||
| `integer` | `0` |
|
||||
| `boolean` | `false` |
|
||||
| `datetime` | zero time |
|
||||
| `enum` | `""` (empty string)|
|
||||
| `stringList` | `[]` (empty list) |
|
||||
| `taskIdList` | `[]` (empty list) |
|
||||
|
||||
Setting a field to `empty` removes it entirely, making it indistinguishable from a field that was never set.
|
||||
|
||||
### Distinguishability by type
|
||||
|
||||
**Enum fields** preserve the missing-vs-set distinction: `""` is not a valid enum member, so `category is empty` only matches tasks where the field was never set (or was cleared). A task with `category = "frontend"` is never empty.
|
||||
|
||||
**Boolean and integer fields** do not preserve this distinction: `false` and `0` are both the zero value and the `empty` value. If you need to tell "never set" from "explicitly false" or "explicitly zero", use an enum field with named values (e.g. `yes` / `no`) instead.
|
||||
|
||||
### Worked examples
|
||||
|
||||
Suppose `blocked` is a custom boolean field:
|
||||
|
||||
| Query | never set | `false` | `true` |
|
||||
|------------------------------------|-----------|---------|--------|
|
||||
| `select where blocked = true` | no | no | yes |
|
||||
| `select where blocked = false` | yes | yes | no |
|
||||
| `select where blocked is empty` | yes | yes | no |
|
||||
| `select where blocked is not empty`| no | no | yes |
|
||||
|
||||
Suppose `category` is a custom enum field with values `[frontend, backend, infra]`:
|
||||
|
||||
| Query | never set | `"frontend"` |
|
||||
|--------------------------------------|-----------|--------------|
|
||||
| `select where category = "frontend"` | no | yes |
|
||||
| `select where category is empty` | yes | no |
|
||||
| `select where category is not empty` | no | yes |
|
||||
|
|
@ -45,6 +45,14 @@ select where status = "done" order by updatedAt desc
|
|||
select where "bug" in tags order by priority asc, createdAt desc
|
||||
```
|
||||
|
||||
Select with limit:
|
||||
|
||||
```sql
|
||||
select order by priority limit 3
|
||||
select where "bug" in tags order by priority limit 5
|
||||
select id, title order by priority limit 2 | clipboard()
|
||||
```
|
||||
|
||||
Create a tiki:
|
||||
|
||||
```sql
|
||||
|
|
@ -136,6 +144,14 @@ Use `call(...)` in a value:
|
|||
create title=call("echo hi")
|
||||
```
|
||||
|
||||
Pipe select results to a shell command or clipboard:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
select id where id = id() | clipboard()
|
||||
select description where id = id() | clipboard()
|
||||
```
|
||||
|
||||
## Before triggers
|
||||
|
||||
Block completion when dependencies remain open:
|
||||
|
|
|
|||
|
|
@ -29,4 +29,5 @@ ready-to-use examples for common workflow patterns
|
|||
- [Types And Values](types-and-values.md): value categories, literals, `empty`, enums, and schema-dependent typing.
|
||||
- [Operators And Built-ins](operators-and-builtins.md): precedence, operators, built-in functions, and shell-adjacent capabilities.
|
||||
- [Validation And Errors](validation-and-errors.md): parse errors, validation failures, edge cases, and strictness rules.
|
||||
- [Custom Fields Reference](custom-fields-reference.md): coercion rules, enum isolation, persistence round-trips, schema evolution, and missing-field semantics.
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ create title="x" dependsOn=dependsOn + tags
|
|||
| `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` |
|
||||
| `blocks(...)` | `list<ref>` | exactly 1 | argument must be `id`, `ref`, or string literal |
|
||||
| `id()` | `id` | 0 | valid only in plugin runtime; resolves to selected tiki ID |
|
||||
| `input()` | declared type | 0 | valid only in plugin actions with `input:` declaration |
|
||||
| `call(...)` | `string` | exactly 1 | argument must be `string` |
|
||||
| `user()` | `string` | 0 | no additional validation |
|
||||
|
||||
|
|
@ -223,6 +224,8 @@ select where blocks(id) is empty
|
|||
select where id() in dependsOn
|
||||
create title=call("echo hi")
|
||||
select where assignee = user()
|
||||
update where id = id() set assignee = input()
|
||||
update where id = id() set tags = tags + [input()]
|
||||
```
|
||||
|
||||
Runtime notes:
|
||||
|
|
@ -231,6 +234,7 @@ Runtime notes:
|
|||
- When a validated statement uses `id()`, plugin execution must provide a non-empty selected task ID.
|
||||
- `id()` is rejected for CLI, event-trigger, and time-trigger semantic runtimes.
|
||||
- `call(...)` is currently rejected by semantic validation.
|
||||
- `input()` returns the value typed by the user at the action prompt. Its return type matches the `input:` declaration on the action (e.g. `input: string` means `input()` returns `string`). Only valid in plugin action statements that declare `input:`. May only appear once per action. Accepted `timestamp` input formats: RFC3339, with YYYY-MM-DD as a convenience fallback.
|
||||
|
||||
`run(...)`
|
||||
|
||||
|
|
@ -246,10 +250,41 @@ after update where new.status = "in progress" run("echo hello")
|
|||
|
||||
## Shell-related forms
|
||||
|
||||
`ruki` includes two shell-related forms:
|
||||
`ruki` includes four shell-related forms:
|
||||
|
||||
- `call(...)` as a string-returning expression
|
||||
- `run(...)` as an `after`-trigger action
|
||||
**`call(...)`** — a string-returning expression
|
||||
|
||||
- `call(...)` returns a string
|
||||
- `run(...)` is used as the top-level action of an `after` trigger
|
||||
- Returns a string result from a shell command
|
||||
- Can be used in any expression context
|
||||
|
||||
**`run(...)` in triggers** — an `after`-trigger action
|
||||
|
||||
- Used as the top-level action of an `after` trigger
|
||||
- Command string may reference `old.` and `new.` fields
|
||||
- Example: `after update where new.status = "in progress" run("echo hello")`
|
||||
|
||||
**`| run(...)` on select** — a pipe suffix
|
||||
|
||||
- Executes a command for each row returned by `select`
|
||||
- Uses positional arguments `$1`, `$2`, etc. for field substitution
|
||||
- Command must be a string literal or expression, but **field references are not allowed in the command** itself
|
||||
- `|` is a statement suffix, not an operator
|
||||
- Example: `select id, title where status = "done" | run("myscript $1 $2")`
|
||||
|
||||
**`| clipboard()` on select** — a pipe suffix
|
||||
|
||||
- Copies selected field values to the system clipboard
|
||||
- Fields within a row are tab-separated; rows are newline-separated
|
||||
- Takes no arguments — grammar enforces empty parentheses
|
||||
- Cross-platform via `atotto/clipboard` (macOS, Linux, Windows)
|
||||
- Example: `select id where id = id() | clipboard()`
|
||||
|
||||
Comparison of pipe targets and trigger actions:
|
||||
|
||||
| Form | Context | Field access | Behavior |
|
||||
|---|---|---|---|
|
||||
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | shell execution via expression evaluation |
|
||||
| pipe `\| run()` | select suffix | field refs disallowed in command | shell execution with positional args `$1`, `$2` |
|
||||
| pipe `\| clipboard()` | select suffix | n/a (no arguments) | writes rows to system clipboard |
|
||||
|
||||
See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ select
|
|||
select title, status
|
||||
select id, title where status = "done"
|
||||
select where "bug" in tags and priority <= 2
|
||||
select where status != "done" order by priority limit 3
|
||||
```
|
||||
|
||||
`create` writes one or more assignments:
|
||||
|
|
@ -63,6 +64,15 @@ delete where id = "TIKI-ABC123"
|
|||
delete where status = "cancelled" and "old" in tags
|
||||
```
|
||||
|
||||
`select` may pipe results to a shell command or to the clipboard:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
select id where id = id() | clipboard()
|
||||
```
|
||||
|
||||
`| run(...)` executes the command for each row with field values as positional arguments (`$1`, `$2`). `| clipboard()` copies the selected fields to the system clipboard.
|
||||
|
||||
## Conditions and expressions
|
||||
|
||||
Conditions support:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ This page explains how `ruki` statements, triggers, conditions, and expressions
|
|||
- Duplicate fields are rejected.
|
||||
- Only bare field names are allowed — `old.` and `new.` qualifiers are not valid in `order by`.
|
||||
|
||||
`limit`
|
||||
|
||||
- Must be a positive integer.
|
||||
- Applied after filtering and sorting, before any pipe action.
|
||||
- If the limit exceeds the result count, all results are returned (no error).
|
||||
|
||||
`create`
|
||||
|
||||
- `create` is a list of assignments.
|
||||
|
|
@ -167,3 +173,69 @@ Binary `+` and `-` are semantic rather than purely numeric:
|
|||
|
||||
For the detailed type rules and built-ins, see [Types And Values](types-and-values.md) and [Operators And Built-ins](operators-and-builtins.md).
|
||||
|
||||
## Pipe actions on select
|
||||
|
||||
`select` statements may include an optional pipe suffix:
|
||||
|
||||
```text
|
||||
select <fields> where <condition> [order by ...] [limit N] | run(<command>)
|
||||
select <fields> where <condition> [order by ...] [limit N] | clipboard()
|
||||
```
|
||||
|
||||
### `| run(...)` — shell execution
|
||||
|
||||
Evaluation model:
|
||||
|
||||
- The `select` runs first, producing zero or more rows.
|
||||
- For each row, the `run()` command is executed with positional arguments (`$1`, `$2`, etc.) substituted from the selected fields in left-to-right order.
|
||||
- Each command execution has a **30-second timeout**.
|
||||
- Command failures are **non-fatal** — remaining rows still execute.
|
||||
- Stdout and stderr are **fire-and-forget** (not captured or returned).
|
||||
|
||||
Rules:
|
||||
|
||||
- Explicit field names are required — `select *` and bare `select` are rejected when used with a pipe.
|
||||
- The command expression must be a string literal or string-typed expression, but **field references are not allowed** in the command string itself.
|
||||
- Positional arguments `$1`, `$2`, etc. are substituted by the runtime before each command execution.
|
||||
|
||||
Example:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
```
|
||||
|
||||
For a task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the command becomes:
|
||||
|
||||
```bash
|
||||
myscript "TIKI-ABC123" "Fix bug"
|
||||
```
|
||||
|
||||
Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference.
|
||||
|
||||
### `| clipboard()` — copy to clipboard
|
||||
|
||||
Evaluation model:
|
||||
|
||||
- The `select` runs first, producing zero or more rows.
|
||||
- The selected field values are written to the system clipboard.
|
||||
- Fields within a row are **tab-separated**; rows are **newline-separated**.
|
||||
- Uses `atotto/clipboard` internally — works on macOS, Linux (requires `xclip` or `xsel`), and Windows.
|
||||
|
||||
Rules:
|
||||
|
||||
- Explicit field names are required — same restriction as `| run(...)`.
|
||||
- `clipboard()` takes no arguments — the grammar enforces empty parentheses.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select id where id = id() | clipboard()
|
||||
select id, title where status = "done" | clipboard()
|
||||
```
|
||||
|
||||
For a single task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the clipboard receives:
|
||||
|
||||
```text
|
||||
TIKI-ABC123 Fix bug
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,10 @@ The following EBNF-style summary shows the grammar:
|
|||
```text
|
||||
statement = selectStmt | createStmt | updateStmt | deleteStmt ;
|
||||
|
||||
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] ;
|
||||
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ "limit" int ] [ pipeAction ] ;
|
||||
fieldList = identifier { "," identifier } ;
|
||||
pipeAction = "|" ( runAction | clipboardAction ) ;
|
||||
clipboardAction = "clipboard" "(" ")" ;
|
||||
createStmt = "create" assignment { assignment } ;
|
||||
|
||||
orderBy = "order" "by" sortField { "," sortField } ;
|
||||
|
|
@ -81,7 +83,8 @@ Notes:
|
|||
- `update` requires both `where` and `set`.
|
||||
- `delete` requires `where`.
|
||||
- `order by` is only valid on `select`, not on subqueries inside `count(...)`.
|
||||
- `asc`, `desc`, `order`, and `by` are contextual keywords — they are only special in the ORDER BY clause.
|
||||
- `limit` truncates the result set to at most N rows, applied after filtering and sorting but before any pipe action.
|
||||
- `asc`, `desc`, `order`, `by`, and `limit` are contextual keywords — they are only special in the SELECT clause.
|
||||
- Bare `select` and `select *` both mean all fields. A field list like `select title, status` projects only the named fields.
|
||||
- `every` wraps a CRUD statement with a periodic interval. Only `create`, `update`, and `delete` are allowed
|
||||
|
||||
|
|
@ -143,6 +146,14 @@ select where status = "done" order by updatedAt desc
|
|||
select where "bug" in tags order by priority asc, createdAt desc
|
||||
```
|
||||
|
||||
Limit:
|
||||
|
||||
```sql
|
||||
select order by priority limit 3
|
||||
select where status != "done" order by priority limit 5
|
||||
select limit 1
|
||||
```
|
||||
|
||||
## Expression grammar
|
||||
|
||||
Expressions support literals, field references, qualifiers, function calls, list literals, parenthesized expressions, subqueries, and left-associative `+` or `-` chains:
|
||||
|
|
|
|||
|
|
@ -248,6 +248,8 @@ The maximum cascade depth is **8**. Termination is graceful — a warning is log
|
|||
|
||||
## The run() action
|
||||
|
||||
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` and `| clipboard()` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). Both `run()` and `clipboard()` are pipe-only targets — they cannot appear as trigger actions.
|
||||
|
||||
When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed:
|
||||
|
||||
- The command runs via `sh -c <command-string>`.
|
||||
|
|
|
|||
|
|
@ -111,10 +111,11 @@ select where due is empty
|
|||
|
||||
`type`
|
||||
|
||||
- normalized through the injected schema
|
||||
- validated through the injected schema against the `types:` section of `workflow.yaml`
|
||||
- production normalization lowercases, trims, and removes separators
|
||||
- default built-in types are `story`, `bug`, `spike`, and `epic`
|
||||
- default aliases include `feature` and `task` mapping to `story`
|
||||
- type keys must be canonical (matching normalized form); aliases are not supported
|
||||
- unknown type values are rejected — no silent fallback
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
- [Type and operator errors](#type-and-operator-errors)
|
||||
- [Enum and list errors](#enum-and-list-errors)
|
||||
- [Order by errors](#order-by-errors)
|
||||
- [Limit errors](#limit-errors)
|
||||
- [Built-in and subquery errors](#built-in-and-subquery-errors)
|
||||
|
||||
## Overview
|
||||
|
|
@ -216,6 +217,67 @@ Order by inside a subquery:
|
|||
select where count(select where status = "done" order by priority) >= 1
|
||||
```
|
||||
|
||||
## Limit errors
|
||||
|
||||
Validation error (must be positive):
|
||||
|
||||
```sql
|
||||
select limit 0
|
||||
```
|
||||
|
||||
Parse errors (invalid token after `limit`):
|
||||
|
||||
```sql
|
||||
select limit -1
|
||||
select limit "three"
|
||||
select limit
|
||||
```
|
||||
|
||||
## Pipe validation errors
|
||||
|
||||
Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions:
|
||||
|
||||
`select *` with pipe:
|
||||
|
||||
```sql
|
||||
select * where status = "done" | run("echo $1")
|
||||
select * where status = "done" | clipboard()
|
||||
```
|
||||
|
||||
Bare `select` with pipe:
|
||||
|
||||
```sql
|
||||
select | run("echo $1")
|
||||
select | clipboard()
|
||||
```
|
||||
|
||||
Both are rejected because explicit field names are required when using a pipe. This applies to both `run()` and `clipboard()` targets.
|
||||
|
||||
Non-string command:
|
||||
|
||||
```sql
|
||||
select id where status = "done" | run(42)
|
||||
```
|
||||
|
||||
Field references in command:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run(title + " " + id)
|
||||
select id, title where status = "done" | run("echo " + title)
|
||||
```
|
||||
|
||||
Field references are not allowed in the pipe command expression itself. Use positional arguments (`$1`, `$2`) instead.
|
||||
|
||||
Pipe on non-select statements:
|
||||
|
||||
```sql
|
||||
update where status = "done" set priority=1 | run("echo done")
|
||||
create title="x" | run("echo created")
|
||||
delete where id = "TIKI-ABC123" | run("echo deleted")
|
||||
```
|
||||
|
||||
Pipe suffix is only valid on `select` statements.
|
||||
|
||||
## Built-in and subquery errors
|
||||
|
||||
Unknown function:
|
||||
|
|
@ -255,3 +317,51 @@ select where select = 1
|
|||
create title=select
|
||||
```
|
||||
|
||||
## input() errors
|
||||
|
||||
`input()` without `input:` declaration on the action:
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee = input()
|
||||
```
|
||||
|
||||
Fails at workflow load time with: `input() requires 'input:' declaration on action`.
|
||||
|
||||
`input:` declared but `input()` not used:
|
||||
|
||||
```yaml
|
||||
- key: "a"
|
||||
label: "Ready"
|
||||
action: update where id = id() set status="ready"
|
||||
input: string
|
||||
```
|
||||
|
||||
Fails at workflow load time: `declares 'input: string' but does not use input()`.
|
||||
|
||||
Duplicate `input()` (more than one call per action):
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee=input(), title=input()
|
||||
```
|
||||
|
||||
Fails with: `input() may only be used once per action`.
|
||||
|
||||
Type mismatch (declared type incompatible with target field):
|
||||
|
||||
```yaml
|
||||
- key: "a"
|
||||
label: "Assign to"
|
||||
action: update where id = id() set assignee = input()
|
||||
input: int
|
||||
```
|
||||
|
||||
Fails at workflow load time: `int` is not assignable to string field `assignee`.
|
||||
|
||||
`input()` with arguments:
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee = input("name")
|
||||
```
|
||||
|
||||
Fails with: `input() takes no arguments`.
|
||||
|
||||
|
|
|
|||
|
|
@ -88,10 +88,11 @@ title: Implement user authentication
|
|||
|
||||
#### type
|
||||
|
||||
Optional string. Default: `story`.
|
||||
Optional string. Defaults to the first type defined in `workflow.yaml`.
|
||||
|
||||
Valid values: `story`, `bug`, `spike`, `epic`. Aliases `feature` and `task` resolve to `story`.
|
||||
In the TUI each type has an icon: Story 🌀, Bug 💥, Spike 🔍, Epic 🗂️.
|
||||
Valid values are the type keys defined in the `types:` section of `workflow.yaml`.
|
||||
Default types: `story`, `bug`, `spike`, `epic`. Each type can have a label and emoji
|
||||
configured in `workflow.yaml`. Aliases are not supported; use the canonical key.
|
||||
|
||||
```yaml
|
||||
type: bug
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ linters:
|
|||
errcheck:
|
||||
check-type-assertions: true
|
||||
|
||||
staticcheck:
|
||||
checks:
|
||||
- "-SA5011" # t.Fatal stops via runtime.Goexit; nil deref after guard is safe
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -1,7 +1,7 @@
|
|||
.PHONY: help build install clean test lint snapshot
|
||||
|
||||
# Build variables
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev")
|
||||
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
LDFLAGS := -ldflags "-X github.com/boolean-maybe/tiki/config.Version=$(VERSION) -X github.com/boolean-maybe/tiki/config.GitCommit=$(COMMIT) -X github.com/boolean-maybe/tiki/config.BuildDate=$(DATE)"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ Follow me on X: [
|
||||
**Update:** [v0.5.0 and custom fields](https://github.com/boolean-maybe/tiki/releases/tag/v0.5.0)
|
||||
|
||||
`tiki` is a terminal-first Markdown workspace for tasks, docs, prompts, and notes stored in your **git** repo
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ this will clone and show a demo project. Once done you can try your own:
|
|||
|
||||
`cd` into your **git** repo and run `tiki init` to initialize.
|
||||
Move your tiki around the board with `Shift ←/Shift →`.
|
||||
Make sure to press `?` for help.
|
||||
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
|
||||
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
|
||||
|
||||
### AI skills
|
||||
|
|
@ -142,7 +142,7 @@ Store your notes in remotes!
|
|||
|
||||
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
|
||||
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
|
||||
Read more by pressing `?` for help
|
||||
Press `?` to open the Action Palette and discover all available actions
|
||||
|
||||
## AI skills
|
||||
|
||||
|
|
|
|||
|
|
@ -1,237 +1,150 @@
|
|||
---
|
||||
name: tiki
|
||||
description: view, create, update, delete tikis and manage dependencies
|
||||
allowed-tools: Read, Grep, Glob, Update, Edit, Write, WriteFile, Bash(git add:*), Bash(git rm:*)
|
||||
allowed-tools: Read, Grep, Glob, Write, Bash(tiki exec:*), Bash(git log:*), Bash(git blame:*)
|
||||
---
|
||||
|
||||
# tiki
|
||||
|
||||
A tiki is a Markdown file in tiki format saved in the project `.doc/tiki` directory
|
||||
with a name like `tiki-abc123.md` in all lower letters.
|
||||
IMPORTANT! files are named in lowercase always
|
||||
If this directory does not exist prompt user for creation
|
||||
A tiki is a task stored as a Markdown file in `.doc/tiki/`.
|
||||
Filename: `tiki-<6char>.md` (lowercase) → ID: `TIKI-<6CHAR>` (uppercase).
|
||||
Example: `tiki-x7f4k2.md` → `TIKI-X7F4K2`
|
||||
|
||||
## tiki ID format
|
||||
All CRUD operations go through `tiki exec '<ruki-statement>'`.
|
||||
This handles validation, triggers, file persistence, and git staging automatically.
|
||||
Never manually edit tiki files or run `git add`/`git rm` — `tiki exec` does it all.
|
||||
|
||||
ID format: `TIKI-ABC123` where ABC123 is 6-char random alphanumeric
|
||||
**Derived from filename, NOT stored in frontmatter**
|
||||
For full `ruki` syntax, see `.doc/doki/doc/ruki/`.
|
||||
|
||||
Examples:
|
||||
- Filename: `tiki-x7f4k2.md` → ID: `TIKI-X7F4K2`
|
||||
## Field reference
|
||||
|
||||
## tiki format
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `id` | immutable, auto-generated `TIKI-XXXXXX` |
|
||||
| `title` | `string` | required on create |
|
||||
| `description` | `string` | markdown body content |
|
||||
| `status` | `status` | from `workflow.yaml`, default `backlog` |
|
||||
| `type` | `type` | bug, feature, task, story, epic |
|
||||
| `priority` | `int` | 1–5 (1=high, 5=low), default 3 |
|
||||
| `points` | `int` | story points 1–10 |
|
||||
| `assignee` | `string` | |
|
||||
| `tags` | `list<string>` | |
|
||||
| `dependsOn` | `list<ref>` | list of tiki IDs |
|
||||
| `due` | `date` | `YYYY-MM-DD` format |
|
||||
| `recurrence` | `recurrence` | cron: `0 0 * * *` (daily), `0 0 * * MON` (weekly), `0 0 1 * *` (monthly) |
|
||||
| `createdBy` | `string` | immutable |
|
||||
| `createdAt` | `timestamp` | immutable |
|
||||
| `updatedAt` | `timestamp` | immutable |
|
||||
|
||||
A tiki format is Markdown with some requirements:
|
||||
Priority descriptions: 1=high, 2=medium-high, 3=medium, 4=medium-low, 5=low.
|
||||
Valid statuses are configurable — check `statuses:` in `~/.config/tiki/workflow.yaml`.
|
||||
|
||||
### frontmatter
|
||||
## Query
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: My ticket
|
||||
type: story
|
||||
status: backlog
|
||||
priority: 3
|
||||
points: 5
|
||||
tags:
|
||||
- markdown
|
||||
- metadata
|
||||
dependsOn:
|
||||
- TIKI-ABC123
|
||||
due: 2026-04-01
|
||||
recurrence: 0 0 * * MON
|
||||
---
|
||||
```sh
|
||||
tiki exec 'select' # all tasks
|
||||
tiki exec 'select title, status' # field projection
|
||||
tiki exec 'select id, title where status = "done"' # filter
|
||||
tiki exec 'select where "bug" in tags order by priority' # tag filter + sort
|
||||
tiki exec 'select where due < now()' # overdue
|
||||
tiki exec 'select where due - now() < 7day' # due within 7 days
|
||||
tiki exec 'select where dependsOn any status != "done"' # blocked tasks
|
||||
tiki exec 'select where assignee = user()' # my tasks
|
||||
```
|
||||
|
||||
where fields can have these values:
|
||||
- type: bug, feature, task, story, epic
|
||||
- status: configurable via `workflow.yaml`. Default statuses: backlog, ready, in_progress, review, done.
|
||||
To find valid statuses for the current project, check the `statuses:` section in `~/.config/tiki/workflow.yaml`.
|
||||
- priority: is any integer number from 1 to 5 where 1 is the highest priority. Mapped to priority description:
|
||||
- high: 1
|
||||
- medium-high: 2
|
||||
- medium: 3
|
||||
- medium-low: 4
|
||||
- low: 5
|
||||
- points: story points from 1 to 10
|
||||
- dependsOn: list of tiki IDs (TIKI-XXXXXX format) this task depends on
|
||||
- due: due date in YYYY-MM-DD format (optional, date-only)
|
||||
- recurrence: recurrence pattern in cron format (optional). Supported: `0 0 * * *` (daily), `0 0 * * MON` (weekly on Monday, etc.), `0 0 1 * *` (monthly)
|
||||
Output is an ASCII table. To read a tiki's full markdown body, use `Read` on `.doc/tiki/tiki-<id>.md`.
|
||||
|
||||
### body
|
||||
## Create
|
||||
|
||||
The body of a tiki is normal Markdown
|
||||
```sh
|
||||
tiki exec 'create title="Fix login"' # minimal
|
||||
tiki exec 'create title="Fix login" priority=2 status="ready" tags=["bug"]' # full
|
||||
tiki exec 'create title="Review" due=2026-04-01 + 2day' # date arithmetic
|
||||
tiki exec 'create title="Sprint review" recurrence="0 0 * * MON"' # recurrence
|
||||
```
|
||||
|
||||
if a tiki needs an attachment it is implemented as a normal markdown link to file syntax for example:
|
||||
- Logs are attached [logs](mylogs.log)
|
||||
- Here is the 
|
||||
- Check out docs: <https://www.markdownguide.org>
|
||||
- Contact: <user@example.com>
|
||||
|
||||
## Describe
|
||||
|
||||
When asked a question about a tiki find its file and read it then answer the question
|
||||
If the question is who created this tiki or who updated it last - use the git username
|
||||
For example:
|
||||
- who created this tiki? use `git log --follow --diff-filter=A -- <file_path>` to see who created it
|
||||
- who edited this tiki? use `git blame <file_path>` to see who edited the file
|
||||
|
||||
## View
|
||||
|
||||
`Created` timestamp is taken from git file creation if available else from the file creation timestamp.
|
||||
|
||||
`Author` is taken from git history as the git user who created the file.
|
||||
|
||||
## Creation
|
||||
|
||||
When asked to create a tiki:
|
||||
|
||||
- Generate a random 6-character alphanumeric ID (lowercase letters and digits)
|
||||
- The filename should be lowercase: `tiki-abc123.md`
|
||||
- If status is not specified use the default status from `~/.config/tiki/workflow.yaml` (typically `backlog`)
|
||||
- If priority is not specified use 3
|
||||
- If type is not specified - prompt the user or use `story` by default
|
||||
|
||||
Example: for random ID `x7f4k2`:
|
||||
- Filename: `tiki-x7f4k2.md`
|
||||
- tiki ID: `TIKI-X7F4K2`
|
||||
Output: `created TIKI-XXXXXX`. Defaults: status from workflow.yaml (typically `backlog`), priority 3, type `story`.
|
||||
|
||||
### Create from file
|
||||
|
||||
if asked to create a tiki from Markdown or text file - create only a single tiki and use the entire content of the
|
||||
file as its description. Title should be a short sentence summarizing the file content
|
||||
|
||||
#### git
|
||||
|
||||
After a new tiki is created `git add` this file.
|
||||
IMPORTANT - only add, never commit the file without user asking and permitting
|
||||
When asked to create a tiki from a file:
|
||||
1. Read the source file
|
||||
2. Summarize its content into a short title
|
||||
3. Use the file content as the description:
|
||||
```sh
|
||||
tiki exec 'create title="Summary of file" description="<escaped content>"'
|
||||
```
|
||||
Escape double quotes in the content with backslash.
|
||||
|
||||
## Update
|
||||
|
||||
When asked to update a tiki - edit its file
|
||||
For example when user says "set TIKI-ABC123 in progress" find its file and edit its frontmatter line from
|
||||
`status: backlog` to `status: in progress`
|
||||
```sh
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set status="done"' # status change
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set priority=1' # priority
|
||||
tiki exec 'update where status = "ready" set status="cancelled"' # bulk update
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set tags=tags + ["urgent"]' # add tag
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set due=2026-04-01' # set due date
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set due=empty' # clear due date
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set recurrence="0 0 * * MON"' # set recurrence
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set recurrence=empty' # clear recurrence
|
||||
```
|
||||
|
||||
### git
|
||||
Output: `updated N tasks`.
|
||||
|
||||
After a tiki is updated `git add` this file
|
||||
IMPORTANT - only add, never commit the file without user asking and permitting
|
||||
### Implement
|
||||
|
||||
## Deletion
|
||||
When asked to implement a tiki and the user approves implementation, set its status to `review`:
|
||||
```sh
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set status="review"'
|
||||
```
|
||||
|
||||
When asked to delete a tiki `git rm` its file
|
||||
If for any reason `git rm` cannot be executed and the file is still there - delete the file
|
||||
## Delete
|
||||
|
||||
## Implement
|
||||
```sh
|
||||
tiki exec 'delete where id = "TIKI-X7F4K2"' # by ID
|
||||
tiki exec 'delete where status = "cancelled"' # bulk
|
||||
```
|
||||
|
||||
When asked to implement a tiki and the user approves implementation change its status to `review` and `git add` it
|
||||
Output: `deleted N tasks`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tikis can declare dependencies on other tikis via the `dependsOn` frontmatter field.
|
||||
Values are tiki IDs in `TIKI-XXXXXX` format. A dependency means this tiki is blocked by or requires the listed tikis.
|
||||
```sh
|
||||
# view a tiki's dependencies
|
||||
tiki exec 'select id, title, status, dependsOn where id = "TIKI-X7F4K2"'
|
||||
|
||||
### View dependencies
|
||||
# find what depends on a tiki (reverse lookup)
|
||||
tiki exec 'select id, title where "TIKI-X7F4K2" in dependsOn'
|
||||
|
||||
When asked about a tiki's dependencies, read its frontmatter `dependsOn` list.
|
||||
For each dependency ID, read that tiki file to show its title, status, and assignee.
|
||||
Highlight any dependencies that are not yet `done` -- these are blockers.
|
||||
# find blocked tasks (any dependency not done)
|
||||
tiki exec 'select id, title where dependsOn any status != "done"'
|
||||
|
||||
Example: "what blocks TIKI-X7F4K2?"
|
||||
1. Read `.doc/tiki/tiki-x7f4k2.md` frontmatter
|
||||
2. For each ID in `dependsOn`, read that tiki and report its status
|
||||
# add a dependency
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set dependsOn=dependsOn + ["TIKI-ABC123"]'
|
||||
|
||||
### Find dependents
|
||||
|
||||
When asked "what depends on TIKI-ABC123?" -- grep all tiki files for that ID in their `dependsOn` field:
|
||||
# remove a dependency
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set dependsOn=dependsOn - ["TIKI-ABC123"]'
|
||||
```
|
||||
grep -l "TIKI-ABC123" .doc/tiki/*.md
|
||||
|
||||
## Provenance
|
||||
|
||||
`ruki` does not access git history. Use git commands for authorship questions:
|
||||
|
||||
```sh
|
||||
# who created this tiki
|
||||
git log --follow --diff-filter=A -- .doc/tiki/tiki-x7f4k2.md
|
||||
|
||||
# who last edited this tiki
|
||||
git blame .doc/tiki/tiki-x7f4k2.md
|
||||
```
|
||||
Then read each match and check if TIKI-ABC123 appears in its `dependsOn` list.
|
||||
|
||||
### Add dependency
|
||||
Created timestamp and author are also available via:
|
||||
```sh
|
||||
tiki exec 'select createdAt, createdBy where id = "TIKI-X7F4K2"'
|
||||
```
|
||||
|
||||
When asked to add a dependency (e.g. "TIKI-X7F4K2 depends on TIKI-ABC123"):
|
||||
1. Verify the target tiki exists: check `.doc/tiki/tiki-abc123.md` exists
|
||||
2. Read `.doc/tiki/tiki-x7f4k2.md`
|
||||
3. Add `TIKI-ABC123` to the `dependsOn` list (create the field if missing)
|
||||
4. Do not add duplicates
|
||||
5. `git add` the modified file
|
||||
## Important
|
||||
|
||||
### Remove dependency
|
||||
|
||||
When asked to remove a dependency:
|
||||
1. Read the tiki file
|
||||
2. Remove the specified ID from `dependsOn`
|
||||
3. If `dependsOn` becomes empty, remove the field entirely (omitempty)
|
||||
4. `git add` the modified file
|
||||
|
||||
### Validation
|
||||
|
||||
- Each value must be a valid tiki ID format: `TIKI-` followed by 6 alphanumeric characters
|
||||
- Each referenced tiki must exist in `.doc/tiki/`
|
||||
- Before adding, verify the target file exists; warn if it doesn't
|
||||
- Circular dependencies: warn the user but do not block (e.g. A depends on B, B depends on A)
|
||||
|
||||
## Due Dates
|
||||
|
||||
Tikis can have a due date via the `due` frontmatter field.
|
||||
The value must be in `YYYY-MM-DD` format (date-only, no time component).
|
||||
|
||||
### Set due date
|
||||
|
||||
When asked to set a due date (e.g. "set TIKI-X7F4K2 due date to April 1st 2026"):
|
||||
1. Read `.doc/tiki/tiki-x7f4k2.md`
|
||||
2. Add or update the `due` field with value `2026-04-01`
|
||||
3. Format must be `YYYY-MM-DD` (4-digit year, 2-digit month, 2-digit day)
|
||||
4. `git add` the modified file
|
||||
|
||||
### Remove due date
|
||||
|
||||
When asked to remove or clear a due date:
|
||||
1. Read the tiki file
|
||||
2. Remove the `due` field entirely (omitempty)
|
||||
3. `git add` the modified file
|
||||
|
||||
### Query by due date
|
||||
|
||||
Users can filter tasks by due date in the TUI using `ruki` select statements:
|
||||
- `select where due = 2026-04-01` - exact match
|
||||
- `select where due < 2026-04-01` - before date
|
||||
- `select where due < now()` - overdue tasks
|
||||
- `select where due - now() < 7day` - due within 7 days
|
||||
|
||||
### Validation
|
||||
|
||||
- Date must be in `YYYY-MM-DD` format
|
||||
- Must be a valid calendar date (no Feb 30, etc.)
|
||||
- Date-only (no time component)
|
||||
|
||||
## Recurrence
|
||||
|
||||
Tikis can have a recurrence pattern via the `recurrence` frontmatter field.
|
||||
The value must be a supported cron pattern. Displayed as English in the TUI (e.g. "Weekly on Monday").
|
||||
This is metadata-only — it does not auto-create tasks on completion.
|
||||
|
||||
### Set recurrence
|
||||
|
||||
When asked to set a recurrence (e.g. "set TIKI-X7F4K2 to recur weekly on Monday"):
|
||||
1. Read `.doc/tiki/tiki-x7f4k2.md`
|
||||
2. Add or update the `recurrence` field with value `0 0 * * MON`
|
||||
3. `git add` the modified file
|
||||
|
||||
Supported patterns:
|
||||
- `0 0 * * *` — Daily
|
||||
- `0 0 * * MON` — Weekly on Monday (through SUN for other days)
|
||||
- `0 0 1 * *` — Monthly
|
||||
|
||||
### Remove recurrence
|
||||
|
||||
When asked to remove or clear a recurrence:
|
||||
1. Read the tiki file
|
||||
2. Remove the `recurrence` field entirely (omitempty)
|
||||
3. `git add` the modified file
|
||||
|
||||
### Validation
|
||||
|
||||
- Must be one of the supported cron patterns listed above
|
||||
- Empty/omitted means no recurrence
|
||||
- `tiki exec` handles `git add` and `git rm` automatically — never do manual git staging for tikis
|
||||
- Never commit without user permission
|
||||
- Exit codes: 0 = ok, 2 = usage error, 3 = startup failure, 4 = query error
|
||||
|
|
|
|||
251
cmd_workflow.go
Normal file
251
cmd_workflow.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runWorkflow dispatches workflow subcommands. Returns an exit code.
|
||||
func runWorkflow(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runWorkflowReset(args[1:])
|
||||
case "install":
|
||||
return runWorkflowInstall(args[1:])
|
||||
case "describe":
|
||||
return runWorkflowDescribe(args[1:])
|
||||
case "--help", "-h":
|
||||
printWorkflowUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown workflow command: %s\n", args[0])
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runWorkflowReset implements `tiki workflow reset [target] --scope`.
|
||||
func runWorkflowReset(args []string) int {
|
||||
positional, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
target := config.ResetTarget(positional)
|
||||
if !config.ValidResetTarget(target) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: unknown target: %q (use config, workflow, or new)\n", positional)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowInstall implements `tiki workflow install <name> --scope`.
|
||||
func runWorkflowInstall(args []string) int {
|
||||
name, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowInstallUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
results, err := config.InstallWorkflow(name, scope, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
fmt.Println("installed", r.Path)
|
||||
} else {
|
||||
fmt.Println("unchanged", r.Path)
|
||||
}
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowDescribe implements `tiki workflow describe <name>`.
|
||||
// describe is a read-only network call, so scope flags are rejected
|
||||
// to keep the CLI surface honest.
|
||||
func runWorkflowDescribe(args []string) int {
|
||||
name, err := parsePositionalOnly(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowDescribeUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowDescribeUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowDescribeUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
desc, err := config.DescribeWorkflow(name, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
if desc == "" {
|
||||
return exitOK
|
||||
}
|
||||
if strings.HasSuffix(desc, "\n") {
|
||||
fmt.Print(desc)
|
||||
} else {
|
||||
fmt.Println(desc)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseScopeArgs extracts an optional positional argument and a required --scope flag.
|
||||
// Returns errHelpRequested for --help/-h.
|
||||
func parseScopeArgs(args []string) (string, config.Scope, error) {
|
||||
var positional string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
scopeStr = "local"
|
||||
}
|
||||
|
||||
return positional, config.Scope(scopeStr), nil
|
||||
}
|
||||
|
||||
// parsePositionalOnly extracts a single positional argument and rejects any
|
||||
// flag other than --help/-h. Used by subcommands that don't take a scope.
|
||||
func parsePositionalOnly(args []string) (string, error) {
|
||||
var positional string
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", errHelpRequested
|
||||
}
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
return positional, nil
|
||||
}
|
||||
|
||||
func printWorkflowUsage() {
|
||||
fmt.Print(`Usage: tiki workflow <command>
|
||||
|
||||
Commands:
|
||||
reset [target] [--scope] Reset config files to defaults
|
||||
install <name> [--scope] Install a workflow from the tiki repository
|
||||
describe <name> Fetch and print a workflow's description
|
||||
|
||||
Run 'tiki workflow <command> --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowResetUsage() {
|
||||
fmt.Print(`Usage: tiki workflow reset [target] [--scope]
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowInstallUsage() {
|
||||
fmt.Print(`Usage: tiki workflow install <name> [--scope]
|
||||
|
||||
Install a named workflow from the tiki repository.
|
||||
Downloads workflow.yaml and new.md into the scope directory,
|
||||
overwriting any existing files.
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
|
||||
Example:
|
||||
tiki workflow install sprint --global
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowDescribeUsage() {
|
||||
fmt.Print(`Usage: tiki workflow describe <name>
|
||||
|
||||
Fetch a workflow's description from the tiki repository and print it.
|
||||
Reads the top-level 'description' field of the named workflow.yaml.
|
||||
|
||||
Example:
|
||||
tiki workflow describe todo
|
||||
`)
|
||||
}
|
||||
414
cmd_workflow_test.go
Normal file
414
cmd_workflow_test.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// setupWorkflowTest creates a temp config dir for workflow commands.
|
||||
func setupWorkflowTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
config.ResetPathManager()
|
||||
t.Cleanup(config.ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
func overrideBaseURL(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
orig := config.DefaultWorkflowBaseURL
|
||||
config.DefaultWorkflowBaseURL = url
|
||||
t.Cleanup(func() { config.DefaultWorkflowBaseURL = orig })
|
||||
}
|
||||
|
||||
func TestParseScopeArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
scope config.Scope
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "global no positional",
|
||||
args: []string{"--global"},
|
||||
positional: "",
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local with positional",
|
||||
args: []string{"workflow", "--local"},
|
||||
positional: "workflow",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "scope before positional",
|
||||
args: []string{"--current", "config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope defaults to local",
|
||||
args: []string{"config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "multiple positional",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple positional arguments",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args defaults to local",
|
||||
args: nil,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, scope, err := parseScopeArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePositionalOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{name: "no args", args: nil},
|
||||
{name: "single positional", args: []string{"sprint"}, positional: "sprint"},
|
||||
{name: "help flag", args: []string{"--help"}, wantErr: errHelpRequested},
|
||||
{name: "short help flag", args: []string{"-h"}, wantErr: errHelpRequested},
|
||||
{name: "rejects scope", args: []string{"sprint", "--global"}, errSubstr: "unknown flag"},
|
||||
{name: "rejects unknown flag", args: []string{"sprint", "--verbose"}, errSubstr: "unknown flag"},
|
||||
{name: "multiple positional", args: []string{"a", "b"}, errSubstr: "multiple positional arguments"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, err := parsePositionalOnly(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflow dispatch tests ---
|
||||
|
||||
func TestRunWorkflow_NoArgs(t *testing.T) {
|
||||
if code := runWorkflow(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_UnknownSubcommand(t *testing.T) {
|
||||
if code := runWorkflow([]string{"bogus"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_Help(t *testing.T) {
|
||||
if code := runWorkflow([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowReset integration tests ---
|
||||
|
||||
func TestRunWorkflowReset_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("custom"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if code := runWorkflowReset([]string{"--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) == "custom" {
|
||||
t.Error("workflow.yaml was not reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_NothingToReset(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"config", "--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_InvalidTarget(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"themes", "--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowReset([]string{"config"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_Help(t *testing.T) {
|
||||
if code := runWorkflowReset([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowInstall integration tests ---
|
||||
|
||||
func TestRunWorkflowInstall_Success(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("sprint workflow"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("sprint template"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"sprint", "--global"}); code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "sprint workflow" {
|
||||
t.Errorf("workflow.yaml = %q, want %q", got, "sprint workflow")
|
||||
}
|
||||
got, _ = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if string(got) != "sprint template" {
|
||||
t.Errorf("new.md = %q, want %q", got, "sprint template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"../../etc", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"nonexistent", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowInstall([]string{"sprint"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_Help(t *testing.T) {
|
||||
if code := runWorkflowInstall([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowDescribe integration tests ---
|
||||
|
||||
func TestRunWorkflowDescribe_Success(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("description: |\n sprint desc\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"sprint"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"../../etc"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"nonexistent"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_UnknownFlag(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"sprint", "--verbose"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_RejectsScopeFlags(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
for _, flag := range []string{"--global", "--local", "--current"} {
|
||||
if code := runWorkflowDescribe([]string{"sprint", flag}); code != exitUsage {
|
||||
t.Errorf("%s: exit code = %d, want %d", flag, code, exitUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_Help(t *testing.T) {
|
||||
if code := runWorkflowDescribe([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_DescribeDispatch(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("description: hi\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflow([]string{"describe", "sprint"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,23 +4,25 @@ import "path"
|
|||
|
||||
// AITool defines a supported AI coding assistant.
|
||||
// To add a new tool, add an entry to the aiTools slice below.
|
||||
// NOTE: also update view/help/tiki.md which lists tool names in prose.
|
||||
// NOTE: the action palette (press Ctrl+A) surfaces available actions; update docs if tool names change.
|
||||
type AITool struct {
|
||||
Key string // config identifier: "claude", "gemini", "codex", "opencode"
|
||||
DisplayName string // human-readable label for UI: "Claude Code"
|
||||
Command string // CLI binary name
|
||||
PromptFlag string // flag preceding the prompt arg, or "" for positional
|
||||
SkillDir string // relative base dir for skills: ".claude/skills"
|
||||
Key string // config identifier: "claude", "gemini", "codex", "opencode"
|
||||
DisplayName string // human-readable label for UI: "Claude Code"
|
||||
Command string // CLI binary name
|
||||
PromptFlag string // flag preceding the prompt arg, or "" for positional
|
||||
SkillDir string // relative base dir for skills: ".claude/skills"
|
||||
SettingsFile string // relative path to local settings file, or "" if none
|
||||
}
|
||||
|
||||
// aiTools is the single source of truth for all supported AI tools.
|
||||
var aiTools = []AITool{
|
||||
{
|
||||
Key: "claude",
|
||||
DisplayName: "Claude Code",
|
||||
Command: "claude",
|
||||
PromptFlag: "--append-system-prompt",
|
||||
SkillDir: ".claude/skills",
|
||||
Key: "claude",
|
||||
DisplayName: "Claude Code",
|
||||
Command: "claude",
|
||||
PromptFlag: "--append-system-prompt",
|
||||
SkillDir: ".claude/skills",
|
||||
SettingsFile: ".claude/settings.local.json",
|
||||
},
|
||||
{
|
||||
Key: "gemini",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ func TestLookupAITool_Found(t *testing.T) {
|
|||
tool, ok := LookupAITool("claude")
|
||||
if !ok {
|
||||
t.Fatal("expected to find claude")
|
||||
return
|
||||
}
|
||||
if tool.Command != "claude" {
|
||||
t.Errorf("expected command 'claude', got %q", tool.Command)
|
||||
|
|
|
|||
|
|
@ -52,10 +52,10 @@ type ColorConfig struct {
|
|||
ContentBackgroundColor Color
|
||||
ContentTextColor Color
|
||||
|
||||
// Search box colors
|
||||
SearchBoxLabelColor Color
|
||||
SearchBoxBackgroundColor Color
|
||||
SearchBoxTextColor Color
|
||||
// Input box colors
|
||||
InputBoxLabelColor Color
|
||||
InputBoxBackgroundColor Color
|
||||
InputBoxTextColor Color
|
||||
|
||||
// Input field colors (used in task detail edit mode)
|
||||
InputFieldBackgroundColor Color
|
||||
|
|
@ -226,10 +226,10 @@ func ColorsFromPalette(p Palette) *ColorConfig {
|
|||
ContentBackgroundColor: p.ContentBackgroundColor,
|
||||
ContentTextColor: p.TextColor,
|
||||
|
||||
// Search box
|
||||
SearchBoxLabelColor: p.TextColor,
|
||||
SearchBoxBackgroundColor: p.TransparentColor,
|
||||
SearchBoxTextColor: p.TextColor,
|
||||
// Input box
|
||||
InputBoxLabelColor: p.TextColor,
|
||||
InputBoxBackgroundColor: p.TransparentColor,
|
||||
InputBoxTextColor: p.TextColor,
|
||||
|
||||
// Input field
|
||||
InputFieldBackgroundColor: p.TransparentColor,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
version: 0.5.0
|
||||
description: |
|
||||
Default tiki workflow. A lightweight kanban-style flow with
|
||||
Backlog → Ready → In Progress → Review → Done, plus Story / Bug / Spike / Epic task types.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
|
|
@ -20,74 +24,118 @@ statuses:
|
|||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
- key: spike
|
||||
label: Spike
|
||||
emoji: "🔍"
|
||||
- key: epic
|
||||
label: Epic
|
||||
emoji: "🗂️"
|
||||
|
||||
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()
|
||||
- key: "y"
|
||||
label: "Copy ID"
|
||||
action: select id where id = id() | clipboard()
|
||||
- key: "Y"
|
||||
label: "Copy content"
|
||||
action: select title, description where id = id() | clipboard()
|
||||
- key: "+"
|
||||
label: "Priority up"
|
||||
action: update where id = id() set priority = priority - 1
|
||||
- key: "-"
|
||||
label: "Priority down"
|
||||
action: update where id = id() set priority = priority + 1
|
||||
- key: "u"
|
||||
label: "Flag urgent"
|
||||
action: update where id = id() set priority=1 tags=tags+["urgent"]
|
||||
hot: false
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
hot: false
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
hot: false
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags=tags-[input()]
|
||||
input: string
|
||||
hot: false
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
|
||||
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: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
key: "F2"
|
||||
|
||||
triggers:
|
||||
- description: block completion with open dependencies
|
||||
|
|
@ -124,3 +172,9 @@ triggers:
|
|||
before delete
|
||||
where old.status = "inProgress"
|
||||
deny "cannot delete an in-progress task — move to backlog or done first"
|
||||
- description: spawn next occurrence when recurring task completes
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "done" and old.recurrence is not empty
|
||||
create title=old.title priority=old.priority tags=old.tags
|
||||
recurrence=old.recurrence due=next_date(old.recurrence) status="backlog"
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ const (
|
|||
TaskBoxPaddingExpanded = 4 // Width padding in expanded mode
|
||||
TaskBoxMinWidth = 10 // Minimum width fallback
|
||||
|
||||
// Search box dimensions
|
||||
SearchBoxHeight = 3
|
||||
// Input box dimensions
|
||||
InputBoxHeight = 3
|
||||
|
||||
// TaskList default visible rows
|
||||
TaskListDefaultMaxRows = 10
|
||||
|
|
|
|||
272
config/fields.go
Normal file
272
config/fields.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// customFieldYAML represents a single field entry in the workflow.yaml fields: section.
|
||||
type customFieldYAML struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
Values []string `yaml:"values,omitempty"` // enum only
|
||||
}
|
||||
|
||||
// customFieldFileData is the minimal YAML structure for reading fields from workflow.yaml.
|
||||
type customFieldFileData struct {
|
||||
Fields []customFieldYAML `yaml:"fields"`
|
||||
}
|
||||
|
||||
// registriesLoaded tracks whether LoadWorkflowRegistries has been called.
|
||||
var registriesLoaded atomic.Bool
|
||||
|
||||
// RequireWorkflowRegistriesLoaded returns an error if LoadWorkflowRegistries
|
||||
// (or LoadStatusRegistry + LoadCustomFields) has not been called yet.
|
||||
// Intended for use by store/template code that needs registries to be ready
|
||||
// but should not auto-load them from disk.
|
||||
func RequireWorkflowRegistriesLoaded() error {
|
||||
if !registriesLoaded.Load() {
|
||||
return fmt.Errorf("workflow registries not loaded; call config.LoadWorkflowRegistries() first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRegistriesLoadedForTest sets the registriesLoaded flag without loading
|
||||
// from disk. Use in tests that call workflow.RegisterCustomFields directly.
|
||||
func MarkRegistriesLoadedForTest() {
|
||||
registriesLoaded.Store(true)
|
||||
}
|
||||
|
||||
// ResetRegistriesLoadedForTest clears the registriesLoaded flag.
|
||||
// Use in tests that need to verify the unloaded-registry error path.
|
||||
func ResetRegistriesLoadedForTest() {
|
||||
registriesLoaded.Store(false)
|
||||
}
|
||||
|
||||
// LoadWorkflowRegistries is the shared startup helper that loads all
|
||||
// workflow-registry-based sections (statuses, types, custom fields) from
|
||||
// workflow.yaml files. Callers must build a fresh ruki.Schema after this returns.
|
||||
func LoadWorkflowRegistries() error {
|
||||
if err := LoadStatusRegistry(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
return err
|
||||
}
|
||||
registriesLoaded.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCustomFields reads the fields: section from all workflow.yaml files,
|
||||
// validates and merges definitions, and registers them with workflow.RegisterCustomFields.
|
||||
// Uses FindRegistryWorkflowFiles (no views filtering) so files with empty views:
|
||||
// still contribute custom field definitions.
|
||||
// Merge semantics: identical redefinitions allowed, conflicting redefinitions error.
|
||||
func LoadCustomFields() error {
|
||||
files := FindRegistryWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
// no workflow files at all — no custom fields to register, clear any stale state
|
||||
workflow.ClearCustomFields()
|
||||
return nil
|
||||
}
|
||||
|
||||
// collect all field definitions with their source file
|
||||
type fieldSource struct {
|
||||
def customFieldYAML
|
||||
file string
|
||||
}
|
||||
var allFields []fieldSource
|
||||
|
||||
for _, path := range files {
|
||||
defs, err := readCustomFieldsFromFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading custom fields from %s: %w", path, err)
|
||||
}
|
||||
for _, d := range defs {
|
||||
allFields = append(allFields, fieldSource{def: d, file: path})
|
||||
}
|
||||
}
|
||||
|
||||
if len(allFields) == 0 {
|
||||
workflow.ClearCustomFields()
|
||||
return nil
|
||||
}
|
||||
|
||||
// merge: identical definitions allowed, conflicting definitions error
|
||||
type mergedField struct {
|
||||
def workflow.FieldDef
|
||||
sourceFile string
|
||||
}
|
||||
merged := make(map[string]*mergedField)
|
||||
|
||||
for _, fs := range allFields {
|
||||
def, err := convertCustomFieldDef(fs.def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q in %s: %w", fs.def.Name, fs.file, err)
|
||||
}
|
||||
|
||||
if existing, ok := merged[def.Name]; ok {
|
||||
if !fieldDefsEqual(existing.def, def) {
|
||||
return fmt.Errorf("conflicting definition for custom field %q: defined differently in %s and %s",
|
||||
def.Name, existing.sourceFile, fs.file)
|
||||
}
|
||||
// identical redefinition — skip
|
||||
continue
|
||||
}
|
||||
|
||||
merged[def.Name] = &mergedField{def: def, sourceFile: fs.file}
|
||||
}
|
||||
|
||||
// build ordered slice for registration
|
||||
defs := make([]workflow.FieldDef, 0, len(merged))
|
||||
for _, m := range merged {
|
||||
defs = append(defs, m.def)
|
||||
}
|
||||
// sort by name for deterministic ordering
|
||||
sort.Slice(defs, func(i, j int) bool {
|
||||
return defs[i].Name < defs[j].Name
|
||||
})
|
||||
|
||||
if err := workflow.RegisterCustomFields(defs); err != nil {
|
||||
return fmt.Errorf("registering custom fields: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("loaded custom fields", "count", len(defs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindRegistryWorkflowFiles returns all workflow.yaml files that exist,
|
||||
// without the views-filtering that FindWorkflowFiles applies.
|
||||
// Used by registry loaders (statuses, custom fields) that need to read
|
||||
// configuration sections regardless of whether the file defines views.
|
||||
func FindRegistryWorkflowFiles() []string {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
candidates := []string{
|
||||
pm.UserConfigWorkflowFile(),
|
||||
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
|
||||
defaultWorkflowFilename, // relative to cwd
|
||||
}
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
result = append(result, path)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// readCustomFieldsFromFile reads the fields: section from a single workflow.yaml.
|
||||
func readCustomFieldsFromFile(path string) ([]customFieldYAML, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fd customFieldFileData
|
||||
if err := yaml.Unmarshal(data, &fd); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
return fd.Fields, nil
|
||||
}
|
||||
|
||||
// convertCustomFieldDef converts a YAML field definition to a workflow.FieldDef.
|
||||
func convertCustomFieldDef(def customFieldYAML) (workflow.FieldDef, error) {
|
||||
if def.Name == "" {
|
||||
return workflow.FieldDef{}, fmt.Errorf("field name is required")
|
||||
}
|
||||
|
||||
if err := workflow.ValidateFieldName(def.Name); err != nil {
|
||||
return workflow.FieldDef{}, err
|
||||
}
|
||||
|
||||
vt, err := parseFieldType(def.Type)
|
||||
if err != nil {
|
||||
return workflow.FieldDef{}, err
|
||||
}
|
||||
|
||||
fd := workflow.FieldDef{
|
||||
Name: def.Name,
|
||||
Type: vt,
|
||||
Custom: true,
|
||||
}
|
||||
|
||||
if vt == workflow.TypeEnum {
|
||||
if len(def.Values) == 0 {
|
||||
return workflow.FieldDef{}, fmt.Errorf("enum field requires non-empty values list")
|
||||
}
|
||||
fd.AllowedValues = make([]string, len(def.Values))
|
||||
copy(fd.AllowedValues, def.Values)
|
||||
} else if len(def.Values) > 0 {
|
||||
return workflow.FieldDef{}, fmt.Errorf("values list is only valid for enum fields")
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// parseFieldType maps workflow.yaml type strings to workflow.ValueType.
|
||||
func parseFieldType(s string) (workflow.ValueType, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "text":
|
||||
return workflow.TypeString, nil
|
||||
case "integer":
|
||||
return workflow.TypeInt, nil
|
||||
case "boolean":
|
||||
return workflow.TypeBool, nil
|
||||
case "datetime":
|
||||
return workflow.TypeTimestamp, nil
|
||||
case "enum":
|
||||
return workflow.TypeEnum, nil
|
||||
case "stringlist":
|
||||
return workflow.TypeListString, nil
|
||||
case "taskidlist":
|
||||
return workflow.TypeListRef, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field type %q (valid: text, integer, boolean, datetime, enum, stringList, taskIdList)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// fieldDefsEqual returns true if two FieldDefs are structurally identical
|
||||
// (same name, same type, and for enums, same normalized values).
|
||||
func fieldDefsEqual(a, b workflow.FieldDef) bool {
|
||||
if a.Name != b.Name || a.Type != b.Type {
|
||||
return false
|
||||
}
|
||||
if a.Type == workflow.TypeEnum {
|
||||
if len(a.AllowedValues) != len(b.AllowedValues) {
|
||||
return false
|
||||
}
|
||||
// require exact spelling and order for duplicate enum declarations
|
||||
for i := range a.AllowedValues {
|
||||
if a.AllowedValues[i] != b.AllowedValues[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
225
config/fields_test.go
Normal file
225
config/fields_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// setupLoadCustomFieldsTest creates temp dirs and configures the path manager
|
||||
// so LoadCustomFields can discover workflow.yaml files.
|
||||
func setupLoadCustomFieldsTest(t *testing.T) (cwdDir string) {
|
||||
t.Helper()
|
||||
workflow.ClearCustomFields()
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cwdDir = t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
return cwdDir
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_BasicTypes(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: notes
|
||||
type: text
|
||||
- name: score
|
||||
type: integer
|
||||
- name: active
|
||||
type: boolean
|
||||
- name: startedAt
|
||||
type: datetime
|
||||
- name: labels
|
||||
type: stringList
|
||||
- name: related
|
||||
type: taskIdList
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
wantType workflow.ValueType
|
||||
}{
|
||||
{"notes", workflow.TypeString},
|
||||
{"score", workflow.TypeInt},
|
||||
{"active", workflow.TypeBool},
|
||||
{"startedAt", workflow.TypeTimestamp},
|
||||
{"labels", workflow.TypeListString},
|
||||
{"related", workflow.TypeListRef},
|
||||
}
|
||||
for _, c := range checks {
|
||||
f, ok := workflow.Field(c.name)
|
||||
if !ok {
|
||||
t.Errorf("Field(%q) not found", c.name)
|
||||
continue
|
||||
}
|
||||
if f.Type != c.wantType {
|
||||
t.Errorf("Field(%q).Type = %v, want %v", c.name, f.Type, c.wantType)
|
||||
}
|
||||
if !f.Custom {
|
||||
t.Errorf("Field(%q).Custom = false, want true", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_EnumWithValues(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values: [low, medium, high, critical]
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
f, ok := workflow.Field("severity")
|
||||
if !ok {
|
||||
t.Fatal("severity field not found")
|
||||
}
|
||||
if f.Type != workflow.TypeEnum {
|
||||
t.Errorf("severity.Type = %v, want TypeEnum", f.Type)
|
||||
}
|
||||
wantVals := []string{"low", "medium", "high", "critical"}
|
||||
if len(f.AllowedValues) != len(wantVals) {
|
||||
t.Fatalf("severity.AllowedValues length = %d, want %d", len(f.AllowedValues), len(wantVals))
|
||||
}
|
||||
for i, v := range wantVals {
|
||||
if f.AllowedValues[i] != v {
|
||||
t.Errorf("AllowedValues[%d] = %q, want %q", i, f.AllowedValues[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_BadTypeRejected(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: broken
|
||||
type: nosuchtype
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadCustomFields()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_EnumWithoutValues(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadCustomFields()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for enum without values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_ConflictingRedefinition(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// write field definition in project config
|
||||
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
|
||||
content1 := `
|
||||
fields:
|
||||
- name: score
|
||||
type: integer
|
||||
`
|
||||
if err := os.WriteFile(projectWorkflow, []byte(content1), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// write conflicting definition in cwd
|
||||
content2 := `
|
||||
fields:
|
||||
- name: score
|
||||
type: text
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content2), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadCustomFields()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for conflicting redefinition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_IdenticalRedefinition(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// write identical definitions in two locations
|
||||
content := `
|
||||
fields:
|
||||
- name: score
|
||||
type: integer
|
||||
`
|
||||
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(projectWorkflow, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
t.Fatalf("identical redefinition should succeed: %v", err)
|
||||
}
|
||||
|
||||
f, ok := workflow.Field("score")
|
||||
if !ok {
|
||||
t.Fatal("score field not found")
|
||||
}
|
||||
if f.Type != workflow.TypeInt {
|
||||
t.Errorf("score.Type = %v, want TypeInt", f.Type)
|
||||
}
|
||||
}
|
||||
136
config/init.go
136
config/init.go
|
|
@ -1,6 +1,7 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
|
@ -25,10 +26,17 @@ func IsProjectInitialized() bool {
|
|||
return info.IsDir()
|
||||
}
|
||||
|
||||
// InitOptions holds user choices from the init dialog.
|
||||
type InitOptions struct {
|
||||
AITools []string
|
||||
SampleTasks bool
|
||||
}
|
||||
|
||||
// PromptForProjectInit presents a Huh form for project initialization.
|
||||
// Returns (selectedAITools, proceed, error)
|
||||
func PromptForProjectInit() ([]string, bool, error) {
|
||||
var selectedAITools []string
|
||||
// Returns (options, proceed, error)
|
||||
func PromptForProjectInit() (InitOptions, bool, error) {
|
||||
var opts InitOptions
|
||||
opts.SampleTasks = true // default enabled
|
||||
|
||||
// Create custom theme with brighter description and help text
|
||||
theme := huh.ThemeCharm()
|
||||
|
|
@ -67,9 +75,9 @@ AI skills extend your AI assistant with commands to manage tasks and documentati
|
|||
Select AI assistants to install (optional), then press Enter to continue.
|
||||
Press Esc to cancel project initialization.`
|
||||
|
||||
options := make([]huh.Option[string], 0, len(AITools()))
|
||||
aiOptions := make([]huh.Option[string], 0, len(AITools()))
|
||||
for _, t := range AITools() {
|
||||
options = append(options, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
|
||||
aiOptions = append(aiOptions, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
|
|
@ -77,9 +85,13 @@ Press Esc to cancel project initialization.`
|
|||
huh.NewMultiSelect[string]().
|
||||
Title("Initialize project").
|
||||
Description(description).
|
||||
Options(options...).
|
||||
Options(aiOptions...).
|
||||
Filterable(false).
|
||||
Value(&selectedAITools),
|
||||
Value(&opts.AITools),
|
||||
huh.NewConfirm().
|
||||
Title("Create sample tasks").
|
||||
Description("Create example tikis in project").
|
||||
Value(&opts.SampleTasks),
|
||||
),
|
||||
).WithTheme(theme).
|
||||
WithKeyMap(keymap).
|
||||
|
|
@ -88,12 +100,12 @@ Press Esc to cancel project initialization.`
|
|||
err := form.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return nil, false, nil
|
||||
return InitOptions{}, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("form error: %w", err)
|
||||
return InitOptions{}, false, fmt.Errorf("form error: %w", err)
|
||||
}
|
||||
|
||||
return selectedAITools, true, nil
|
||||
return opts, true, nil
|
||||
}
|
||||
|
||||
// EnsureProjectInitialized bootstraps the project if .doc/tiki is missing.
|
||||
|
|
@ -106,7 +118,7 @@ func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bo
|
|||
return false, fmt.Errorf("failed to stat task directory: %w", err)
|
||||
}
|
||||
|
||||
selectedTools, proceed, err := PromptForProjectInit()
|
||||
opts, proceed, err := PromptForProjectInit()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to prompt for project initialization: %w", err)
|
||||
}
|
||||
|
|
@ -114,18 +126,18 @@ func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bo
|
|||
return false, nil
|
||||
}
|
||||
|
||||
if err := BootstrapSystem(); err != nil {
|
||||
if err := BootstrapSystem(opts.SampleTasks); err != nil {
|
||||
return false, fmt.Errorf("failed to bootstrap project: %w", err)
|
||||
}
|
||||
|
||||
// Install selected AI skills
|
||||
if len(selectedTools) > 0 {
|
||||
if err := installAISkills(selectedTools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
|
||||
if len(opts.AITools) > 0 {
|
||||
if err := installAISkills(opts.AITools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
|
||||
// Non-fatal - log warning but continue
|
||||
slog.Warn("some AI skills failed to install", "error", err)
|
||||
fmt.Println("You can manually copy ai/skills/tiki/SKILL.md and ai/skills/doki/SKILL.md to the appropriate directories.")
|
||||
} else {
|
||||
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(selectedTools, ", "))
|
||||
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(opts.AITools, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +157,20 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
|
|||
return fmt.Errorf("embedded doki SKILL.md content is empty")
|
||||
}
|
||||
|
||||
type skillDef struct {
|
||||
name string
|
||||
content string
|
||||
}
|
||||
skills := []skillDef{
|
||||
{"tiki", tikiSkillMdContent},
|
||||
{"doki", dokiSkillMdContent},
|
||||
}
|
||||
|
||||
skillNames := make([]string, len(skills))
|
||||
for i, s := range skills {
|
||||
skillNames[i] = s.name
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, toolKey := range selectedTools {
|
||||
tool, ok := LookupAITool(toolKey)
|
||||
|
|
@ -153,14 +179,7 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
|
|||
continue
|
||||
}
|
||||
|
||||
// install each skill type (tiki, doki)
|
||||
for _, skill := range []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"tiki", tikiSkillMdContent},
|
||||
{"doki", dokiSkillMdContent},
|
||||
} {
|
||||
for _, skill := range skills {
|
||||
path := tool.SkillPath(skill.name)
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
|
||||
|
|
@ -172,6 +191,12 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
|
|||
slog.Info("installed AI skill", "tool", toolKey, "skill", skill.name, "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
if tool.SettingsFile != "" {
|
||||
if err := ensureSkillPermissions(tool.SettingsFile, skillNames); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s settings: %w", toolKey, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
|
|
@ -179,3 +204,68 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillPermissionEntry(name string) string {
|
||||
return fmt.Sprintf("Skill(%s)", name)
|
||||
}
|
||||
|
||||
// ensureSkillPermissions creates or updates a settings file to include
|
||||
// Skill(<name>) entries in permissions.allow for each given skill name.
|
||||
// Existing permissions and other top-level keys are preserved.
|
||||
func ensureSkillPermissions(settingsPath string, skillNames []string) error {
|
||||
settings := make(map[string]any)
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("reading %s: %w", settingsPath, err)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return fmt.Errorf("parsing %s: %w", settingsPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
perms, _ := settings["permissions"].(map[string]any)
|
||||
if perms == nil {
|
||||
perms = make(map[string]any)
|
||||
settings["permissions"] = perms
|
||||
}
|
||||
|
||||
allowRaw, _ := perms["allow"].([]any)
|
||||
existing := make(map[string]bool, len(allowRaw))
|
||||
for _, v := range allowRaw {
|
||||
if s, ok := v.(string); ok {
|
||||
existing[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, name := range skillNames {
|
||||
entry := skillPermissionEntry(name)
|
||||
if !existing[entry] {
|
||||
allowRaw = append(allowRaw, entry)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
perms["allow"] = allowRaw
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling settings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0750); err != nil {
|
||||
return fmt.Errorf("creating directory for %s: %w", settingsPath, err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for user settings files
|
||||
if err := os.WriteFile(settingsPath, append(out, '\n'), 0644); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", settingsPath, err)
|
||||
}
|
||||
|
||||
slog.Info("updated Claude settings", "path", settingsPath, "skills", skillNames)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
182
config/init_test.go
Normal file
182
config/init_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const settingsFile = ".claude/settings.local.json"
|
||||
|
||||
func TestEnsureSkillPermissions_CreatesFile(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki", "doki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
allow := getAllow(t, got)
|
||||
|
||||
if len(allow) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d: %v", len(allow), allow)
|
||||
}
|
||||
if allow[0] != "Skill(tiki)" || allow[1] != "Skill(doki)" {
|
||||
t.Errorf("unexpected entries: %v", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_MergesExisting(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{"Bash(go test:*)"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
allow := getAllow(t, got)
|
||||
|
||||
if len(allow) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d: %v", len(allow), allow)
|
||||
}
|
||||
if allow[0] != "Bash(go test:*)" {
|
||||
t.Errorf("existing entry clobbered: %v", allow)
|
||||
}
|
||||
if allow[1] != "Skill(tiki)" {
|
||||
t.Errorf("new entry missing: %v", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_AlreadyPresent(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{"Skill(tiki)", "Skill(doki)"},
|
||||
},
|
||||
})
|
||||
|
||||
before, _ := os.ReadFile(settingsFile)
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki", "doki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile(settingsFile)
|
||||
|
||||
if string(before) != string(after) {
|
||||
t.Error("file was modified despite all skills already present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_PreservesOtherKeys(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"outputStyle": "Explanatory",
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{"Bash(go test:*)"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
style, ok := got["outputStyle"].(string)
|
||||
if !ok || style != "Explanatory" {
|
||||
t.Errorf("outputStyle not preserved: got %v", got["outputStyle"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_EmptyPermissions(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"permissions": map[string]any{},
|
||||
})
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
allow := getAllow(t, got)
|
||||
if len(allow) != 1 || allow[0] != "Skill(tiki)" {
|
||||
t.Errorf("unexpected entries: %v", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_MalformedJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(settingsFile), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for test fixture files
|
||||
if err := os.WriteFile(settingsFile, []byte("{invalid json"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ensureSkillPermissions(settingsFile, []string{"tiki"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func readSettings(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(settingsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", settingsFile, err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("parsing %s: %v", settingsFile, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getAllow(t *testing.T, settings map[string]any) []string {
|
||||
t.Helper()
|
||||
perms, _ := settings["permissions"].(map[string]any)
|
||||
if perms == nil {
|
||||
t.Fatal("missing permissions key")
|
||||
}
|
||||
rawAllow, _ := perms["allow"].([]any)
|
||||
if rawAllow == nil {
|
||||
t.Fatal("missing allow key")
|
||||
}
|
||||
allow := make([]string, len(rawAllow))
|
||||
for i, v := range rawAllow {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
t.Fatalf("allow[%d] is not a string: %v", i, v)
|
||||
}
|
||||
allow[i] = s
|
||||
}
|
||||
return allow
|
||||
}
|
||||
|
||||
func writeSettings(t *testing.T, settings map[string]any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(settingsFile), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for test fixture files
|
||||
if err := os.WriteFile(settingsFile, append(data, '\n'), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
121
config/install.go
Normal file
121
config/install.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
httpTimeout = 15 * time.Second
|
||||
maxResponseSize = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
var DefaultWorkflowBaseURL = "https://raw.githubusercontent.com/boolean-maybe/tiki/main"
|
||||
|
||||
var validWorkflowName = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// InstallResult describes the outcome for a single installed file.
|
||||
type InstallResult struct {
|
||||
Path string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
var installFiles = []string{
|
||||
defaultWorkflowFilename,
|
||||
templateFilename,
|
||||
}
|
||||
|
||||
// InstallWorkflow fetches a named workflow from baseURL and writes its files
|
||||
// to the directory for the given scope, overwriting existing files.
|
||||
// baseURL is the root URL before "/workflows" (e.g. "https://raw.githubusercontent.com/boolean-maybe/tiki/main").
|
||||
func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetched := make(map[string]string, len(installFiles))
|
||||
for _, filename := range installFiles {
|
||||
content, err := fetchWorkflowFile(baseURL, name, filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s/%s: %w", name, filename, err)
|
||||
}
|
||||
fetched[filename] = string(content)
|
||||
}
|
||||
|
||||
var results []InstallResult
|
||||
for _, filename := range installFiles {
|
||||
path := filepath.Join(dir, filename)
|
||||
changed, err := writeFileIfChanged(path, fetched[filename])
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("write %s: %w", filename, err)
|
||||
}
|
||||
results = append(results, InstallResult{Path: path, Changed: changed})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DescribeWorkflow fetches the workflow.yaml for name from baseURL and
|
||||
// returns the value of its top-level `description:` field. Returns empty
|
||||
// string if the field is absent.
|
||||
func DescribeWorkflow(name, baseURL string) (string, error) {
|
||||
body, err := fetchWorkflowFile(baseURL, name, defaultWorkflowFilename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var wf struct {
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &wf); err != nil {
|
||||
return "", fmt.Errorf("parse %s/workflow.yaml: %w", name, err)
|
||||
}
|
||||
return wf.Description, nil
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
// fetchWorkflowFile validates the workflow name and downloads a single file
|
||||
// from baseURL. Returns the raw body bytes.
|
||||
func fetchWorkflowFile(baseURL, name, filename string) ([]byte, error) {
|
||||
if !validWorkflowName.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/workflows/%s/%s", baseURL, name, filename)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("workflow %q not found (%s)", name, filename)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
213
config/install_test.go
Normal file
213
config/install_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallWorkflow_Success(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("---\ntitle:\n---\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on fresh install", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != "statuses:\n - key: todo\n" {
|
||||
t.Errorf("workflow.yaml content = %q", string(got))
|
||||
}
|
||||
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != "---\ntitle:\n---\n" {
|
||||
t.Errorf("new.md content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_Overwrites(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("old content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("new content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on overwrite", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "new content" {
|
||||
t.Errorf("workflow.yaml not overwritten: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("nonexistent", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AlreadyUpToDate(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("same content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := InstallWorkflow("sprint", ScopeGlobal, server.URL); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
t.Errorf("expected %s to be unchanged on repeat install", r.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_InvalidName(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
_, err := InstallWorkflow(name, ScopeGlobal, "http://unused")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_Success(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("description: |\n Sprint workflow.\n Two-week cycles.\nstatuses:\n - key: todo\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
desc, err := DescribeWorkflow("sprint", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeWorkflow() error = %v", err)
|
||||
}
|
||||
want := "Sprint workflow.\nTwo-week cycles.\n"
|
||||
if desc != want {
|
||||
t.Errorf("description = %q, want %q", desc, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_NoDescriptionField(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
desc, err := DescribeWorkflow("sprint", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeWorkflow() error = %v", err)
|
||||
}
|
||||
if desc != "" {
|
||||
t.Errorf("description = %q, want empty", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := DescribeWorkflow("nonexistent", server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_InvalidName(t *testing.T) {
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
if _, err := DescribeWorkflow(name, "http://unused"); err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AtomicFetch(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("partial", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
|
||||
for _, filename := range []string{"workflow.yaml", "new.md"} {
|
||||
if _, statErr := os.Stat(filepath.Join(tikiDir, filename)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should not exist after fetch failure", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ var lastConfigFile string
|
|||
|
||||
// Config holds all application configuration loaded from config.yaml
|
||||
type Config struct {
|
||||
Version string `mapstructure:"version"`
|
||||
|
||||
// Logging configuration
|
||||
Logging struct {
|
||||
Level string `mapstructure:"level"` // "debug", "info", "warn", "error"
|
||||
|
|
@ -190,21 +192,44 @@ 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"`
|
||||
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
|
||||
Types []map[string]interface{} `yaml:"types,omitempty"`
|
||||
Views viewsFileData `yaml:"views,omitempty"`
|
||||
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
|
||||
Fields []map[string]interface{} `yaml:"fields,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 +237,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 +292,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 +321,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 +335,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 {
|
||||
|
|
@ -494,6 +494,59 @@ triggers:
|
|||
}
|
||||
}
|
||||
|
||||
func TestSavePluginViewMode_PreservesDescription(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
workflowContent := `description: |
|
||||
Release workflow. Coordinate feature rollout through
|
||||
Planned → Building → Staging → Canary → Released.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
views:
|
||||
- name: Kanban
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Done
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wf, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile failed: %v", err)
|
||||
}
|
||||
wantDesc := "Release workflow. Coordinate feature rollout through\nPlanned → Building → Staging → Canary → Released.\n"
|
||||
if wf.Description != wantDesc {
|
||||
t.Errorf("description after read = %q, want %q", wf.Description, wantDesc)
|
||||
}
|
||||
|
||||
if len(wf.Views.Plugins) > 0 {
|
||||
wf.Views.Plugins[0]["view"] = "compact"
|
||||
}
|
||||
if err := writeWorkflowFile(workflowPath, wf); err != nil {
|
||||
t.Fatalf("writeWorkflowFile failed: %v", err)
|
||||
}
|
||||
|
||||
wf2, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile after write failed: %v", err)
|
||||
}
|
||||
if wf2.Description != wantDesc {
|
||||
t.Errorf("description after round-trip = %q, want %q", wf2.Description, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
// Reset appConfig
|
||||
appConfig = nil
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
---
|
||||
title:
|
||||
type: story
|
||||
status: backlog
|
||||
points: 1
|
||||
priority: 3
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -324,6 +324,9 @@ func GetUserConfigWorkflowFile() string {
|
|||
return mustGetPathManager().UserConfigWorkflowFile()
|
||||
}
|
||||
|
||||
// configFilename is the default name for the configuration file
|
||||
const configFilename = "config.yaml"
|
||||
|
||||
// defaultWorkflowFilename is the default name for the workflow configuration file
|
||||
const defaultWorkflowFilename = "workflow.yaml"
|
||||
|
||||
|
|
@ -372,21 +375,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.
|
||||
|
|
|
|||
168
config/reset.go
Normal file
168
config/reset.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Scope identifies which config tier to operate on.
|
||||
type Scope string
|
||||
|
||||
// ResetTarget identifies which config file to reset.
|
||||
type ResetTarget string
|
||||
|
||||
const (
|
||||
ScopeGlobal Scope = "global"
|
||||
ScopeLocal Scope = "local"
|
||||
ScopeCurrent Scope = "current"
|
||||
|
||||
TargetAll ResetTarget = ""
|
||||
TargetConfig ResetTarget = "config"
|
||||
TargetWorkflow ResetTarget = "workflow"
|
||||
TargetNew ResetTarget = "new"
|
||||
)
|
||||
|
||||
// resetEntry pairs a filename with the default content to restore for global scope.
|
||||
// If defaultContent is empty, the file is always deleted (no embedded default exists).
|
||||
type resetEntry struct {
|
||||
filename string
|
||||
defaultContent string
|
||||
}
|
||||
|
||||
var resetEntries = []resetEntry{
|
||||
// TODO: embed a default config.yaml once one exists; until then, global reset deletes the file
|
||||
{filename: configFilename, defaultContent: ""},
|
||||
{filename: defaultWorkflowFilename, defaultContent: defaultWorkflowYAML},
|
||||
{filename: templateFilename, defaultContent: defaultNewTaskTemplate},
|
||||
}
|
||||
|
||||
// ResetConfig resets configuration files for the given scope and target.
|
||||
// Returns the list of file paths that were actually modified or deleted.
|
||||
func ResetConfig(scope Scope, target ResetTarget) ([]string, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := filterEntries(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var affected []string
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(dir, e.filename)
|
||||
changed, err := resetFile(path, scope, e.defaultContent)
|
||||
if err != nil {
|
||||
return affected, fmt.Errorf("reset %s: %w", e.filename, err)
|
||||
}
|
||||
if changed {
|
||||
affected = append(affected, path)
|
||||
}
|
||||
}
|
||||
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// resolveDir returns the directory path for the given scope.
|
||||
func resolveDir(scope Scope) (string, error) {
|
||||
switch scope {
|
||||
case ScopeGlobal:
|
||||
return GetConfigDir(), nil
|
||||
case ScopeLocal:
|
||||
if !IsProjectInitialized() {
|
||||
return "", fmt.Errorf("not in an initialized tiki project (run 'tiki init' first)")
|
||||
}
|
||||
return GetProjectConfigDir(), nil
|
||||
case ScopeCurrent:
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
return cwd, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown scope: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidResetTarget reports whether target is a recognized reset target.
|
||||
func ValidResetTarget(target ResetTarget) bool {
|
||||
switch target {
|
||||
case TargetAll, TargetConfig, TargetWorkflow, TargetNew:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// filterEntries returns the reset entries matching the target.
|
||||
func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
||||
if target == TargetAll {
|
||||
return resetEntries, nil
|
||||
}
|
||||
var filename string
|
||||
switch target {
|
||||
case TargetConfig:
|
||||
filename = configFilename
|
||||
case TargetWorkflow:
|
||||
filename = defaultWorkflowFilename
|
||||
case TargetNew:
|
||||
filename = templateFilename
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown target: %q (use config, workflow, or new)", target)
|
||||
}
|
||||
for _, e := range resetEntries {
|
||||
if e.filename == filename {
|
||||
return []resetEntry{e}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no reset entry for target %q", target)
|
||||
}
|
||||
|
||||
// resetFile either overwrites or deletes a file depending on scope and available defaults.
|
||||
// For global scope with non-empty default content, the file is overwritten.
|
||||
// Otherwise the file is deleted. Returns true if the file was changed.
|
||||
func resetFile(path string, scope Scope, defaultContent string) (bool, error) {
|
||||
if scope == ScopeGlobal && defaultContent != "" {
|
||||
return writeFileIfChanged(path, defaultContent)
|
||||
}
|
||||
return deleteIfExists(path)
|
||||
}
|
||||
|
||||
// writeFileIfChanged writes content to path, skipping if the file already has identical content.
|
||||
// Returns true if the file was actually changed.
|
||||
func writeFileIfChanged(path string, content string) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
return writeFile(path, content)
|
||||
}
|
||||
|
||||
// writeFile writes content to path unconditionally, creating parent dirs if needed.
|
||||
func writeFile(path string, content string) (bool, error) {
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for config directory
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return false, fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for config file
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// deleteIfExists removes a file if it exists. Returns true if the file was deleted.
|
||||
func deleteIfExists(path string) (bool, error) {
|
||||
err := os.Remove(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
268
config/reset_test.go
Normal file
268
config/reset_test.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupResetTest creates a temp config dir, sets XDG_CONFIG_HOME, and resets
|
||||
// the path manager so GetConfigDir() points to the temp dir.
|
||||
// Returns the tiki config dir (e.g. <tmp>/tiki).
|
||||
func setupResetTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
// writeTestFile is a test helper that writes content to path.
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// seed all three files with custom content
|
||||
writeTestFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetAll)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 3 {
|
||||
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
|
||||
}
|
||||
|
||||
// config.yaml should be deleted (no embedded default)
|
||||
if _, err := os.Stat(filepath.Join(tikiDir, "config.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("config.yaml should be deleted after global reset")
|
||||
}
|
||||
|
||||
// workflow.yaml should contain embedded default
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultWorkflowYAML() {
|
||||
t.Error("workflow.yaml does not match embedded default after global reset")
|
||||
}
|
||||
|
||||
// new.md should contain embedded default
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultNewTaskTemplate() {
|
||||
t.Error("new.md does not match embedded default after global reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalSingleTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
target ResetTarget
|
||||
filename string
|
||||
deleted bool // true = file deleted, false = file overwritten with default
|
||||
}{
|
||||
{TargetConfig, "config.yaml", true},
|
||||
{TargetWorkflow, "workflow.yaml", false},
|
||||
{TargetNew, "new.md", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.target), func(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
writeTestFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, tt.target)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(tikiDir, tt.filename))
|
||||
if tt.deleted {
|
||||
if !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should be deleted", tt.filename)
|
||||
}
|
||||
} else {
|
||||
if statErr != nil {
|
||||
t.Errorf("%s should exist after reset: %v", tt.filename, statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalDeletesFiles(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// set up project dir with .doc/tiki so IsProjectInitialized() passes
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(filepath.Join(docDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
// seed project config files
|
||||
writeTestFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
|
||||
// also write global defaults so we can verify local doesn't overwrite
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 3 {
|
||||
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
|
||||
}
|
||||
|
||||
// all project files should be deleted
|
||||
for _, name := range []string{"config.yaml", "workflow.yaml", "new.md"} {
|
||||
if _, err := os.Stat(filepath.Join(docDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("project %s should be deleted after local reset", name)
|
||||
}
|
||||
}
|
||||
|
||||
// global workflow should be untouched
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("global workflow.yaml should still exist: %v", err)
|
||||
}
|
||||
if string(got) != "global\n" {
|
||||
t.Error("global workflow.yaml should be untouched after local reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
writeTestFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cwdDir, "workflow.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("cwd workflow.yaml should be deleted after current reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_IdempotentOnMissingFiles(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
// reset when no files exist — should succeed with 0 affected
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 0 {
|
||||
t.Errorf("expected 0 affected files for missing config.yaml, got %d", len(affected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalWorkflowCreatesDir(t *testing.T) {
|
||||
// use a fresh temp dir where tiki subdir doesn't exist yet
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
|
||||
// should have created the directory and written the default
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultWorkflowYAML() {
|
||||
t.Error("workflow.yaml should match embedded default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// write the embedded default content — reset should detect no change
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 0 {
|
||||
t.Errorf("expected 0 affected files when already default, got %d", len(affected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidResetTarget(t *testing.T) {
|
||||
valid := []ResetTarget{TargetAll, TargetConfig, TargetWorkflow, TargetNew}
|
||||
for _, target := range valid {
|
||||
if !ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = false, want true", target)
|
||||
}
|
||||
}
|
||||
invalid := []ResetTarget{"themes", "invalid", "reset"}
|
||||
for _, target := range invalid {
|
||||
if ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = true, want false", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) {
|
||||
// point projectRoot at a temp dir that has no .doc/tiki
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = t.TempDir() // empty dir — not initialized
|
||||
|
||||
_, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for uninitialized project, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, "not in an initialized tiki project") {
|
||||
t.Errorf("unexpected error message: %s", msg)
|
||||
}
|
||||
}
|
||||
47
config/shipped_workflows_test.go
Normal file
47
config/shipped_workflows_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestShippedWorkflows_HaveDescription ensures every workflow.yaml shipped in
|
||||
// the repo's top-level workflows/ directory has a non-empty top-level
|
||||
// description field and parses cleanly as a full workflowFileData.
|
||||
// Guards against a maintainer dropping the description when adding a new
|
||||
// shipped workflow.
|
||||
func TestShippedWorkflows_HaveDescription(t *testing.T) {
|
||||
matches, err := filepath.Glob("../workflows/*/workflow.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("glob shipped workflows: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("no shipped workflows found at ../workflows/*/workflow.yaml")
|
||||
}
|
||||
|
||||
for _, path := range matches {
|
||||
t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var desc struct {
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &desc); err != nil {
|
||||
t.Fatalf("unmarshal description from %s: %v", path, err)
|
||||
}
|
||||
if desc.Description == "" {
|
||||
t.Errorf("%s: missing or empty top-level description", path)
|
||||
}
|
||||
|
||||
if _, err := readWorkflowFile(path); err != nil {
|
||||
t.Errorf("readWorkflowFile(%s) failed: %v", path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -28,38 +28,37 @@ var (
|
|||
registryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// LoadStatusRegistry reads the statuses: section from workflow.yaml files.
|
||||
// The last file from FindWorkflowFiles() that contains a non-empty statuses list wins
|
||||
// (most specific location takes precedence, matching plugin merge behavior).
|
||||
// LoadStatusRegistry reads statuses: and types: from workflow.yaml files.
|
||||
// Both registries are loaded into locals first, then published atomically
|
||||
// so no intermediate state exists where one is updated and the other stale.
|
||||
// Returns an error if no statuses are defined anywhere (no Go fallback).
|
||||
func LoadStatusRegistry() error {
|
||||
files := FindWorkflowFiles()
|
||||
files := FindRegistryWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no workflow.yaml found; statuses must be defined in workflow.yaml")
|
||||
}
|
||||
|
||||
reg, path, err := loadStatusRegistryFromFiles(files)
|
||||
statusReg, statusPath, err := loadStatusRegistryFromFiles(files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reg == nil {
|
||||
if statusReg == nil {
|
||||
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
|
||||
}
|
||||
|
||||
registryMu.Lock()
|
||||
globalStatusRegistry = reg
|
||||
registryMu.Unlock()
|
||||
slog.Debug("loaded status registry", "file", path, "count", len(reg.All()))
|
||||
|
||||
// also initialize type registry with defaults
|
||||
typeReg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
|
||||
typeReg, typePath, err := loadTypeRegistryFromFiles(files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initializing type registry: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// publish both atomically
|
||||
registryMu.Lock()
|
||||
globalStatusRegistry = statusReg
|
||||
globalTypeRegistry = typeReg
|
||||
registryMu.Unlock()
|
||||
|
||||
slog.Debug("loaded status registry", "file", statusPath, "count", len(statusReg.All()))
|
||||
slog.Debug("loaded type registry", "file", typePath, "count", len(typeReg.All()))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +115,8 @@ func MaybeGetTypeRegistry() (*workflow.TypeRegistry, bool) {
|
|||
}
|
||||
|
||||
// ResetStatusRegistry replaces the global registry with one built from the given defs.
|
||||
// Intended for tests only.
|
||||
// Also resets types to built-in defaults and clears custom fields so test helpers
|
||||
// don't leak registry state. Intended for tests only.
|
||||
func ResetStatusRegistry(defs []workflow.StatusDef) {
|
||||
reg, err := workflow.NewStatusRegistry(defs)
|
||||
if err != nil {
|
||||
|
|
@ -130,17 +130,35 @@ func ResetStatusRegistry(defs []workflow.StatusDef) {
|
|||
globalStatusRegistry = reg
|
||||
globalTypeRegistry = typeReg
|
||||
registryMu.Unlock()
|
||||
workflow.ClearCustomFields()
|
||||
registriesLoaded.Store(true)
|
||||
}
|
||||
|
||||
// ClearStatusRegistry removes the global registries. Intended for test teardown.
|
||||
// ResetTypeRegistry replaces the global type registry with one built from the
|
||||
// given defs, without touching the status registry. Intended for tests that
|
||||
// need custom type configurations while keeping existing status setup.
|
||||
func ResetTypeRegistry(defs []workflow.TypeDef) {
|
||||
reg, err := workflow.NewTypeRegistry(defs)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ResetTypeRegistry: %v", err))
|
||||
}
|
||||
registryMu.Lock()
|
||||
globalTypeRegistry = reg
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearStatusRegistry removes the global registries and clears custom fields.
|
||||
// Intended for test teardown.
|
||||
func ClearStatusRegistry() {
|
||||
registryMu.Lock()
|
||||
globalStatusRegistry = nil
|
||||
globalTypeRegistry = nil
|
||||
registryMu.Unlock()
|
||||
workflow.ClearCustomFields()
|
||||
registriesLoaded.Store(false)
|
||||
}
|
||||
|
||||
// --- internal ---
|
||||
// --- internal: statuses ---
|
||||
|
||||
// workflowStatusData is the YAML shape we unmarshal to extract just the statuses key.
|
||||
type workflowStatusData struct {
|
||||
|
|
@ -164,3 +182,99 @@ func loadStatusesFromFile(path string) (*workflow.StatusRegistry, error) {
|
|||
|
||||
return workflow.NewStatusRegistry(ws.Statuses)
|
||||
}
|
||||
|
||||
// --- internal: types ---
|
||||
|
||||
// validTypeDefKeys is the set of allowed keys inside a types: entry.
|
||||
var validTypeDefKeys = map[string]bool{
|
||||
"key": true, "label": true, "emoji": true,
|
||||
}
|
||||
|
||||
// loadTypesFromFile loads types from a single workflow.yaml.
|
||||
// Returns (registry, present, error):
|
||||
// - (nil, false, nil) when the types: key is absent — file does not override
|
||||
// - (reg, true, nil) when types: is present and valid
|
||||
// - (nil, true, err) when types: is present but invalid (empty list, bad entries)
|
||||
func loadTypesFromFile(path string) (*workflow.TypeRegistry, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
// first pass: check whether the types key exists at all
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
rawTypes, exists := raw["types"]
|
||||
if !exists {
|
||||
return nil, false, nil // absent — no opinion
|
||||
}
|
||||
|
||||
// present: validate the raw structure
|
||||
typesSlice, ok := rawTypes.([]interface{})
|
||||
if !ok {
|
||||
return nil, true, fmt.Errorf("types: must be a list, got %T", rawTypes)
|
||||
}
|
||||
if len(typesSlice) == 0 {
|
||||
return nil, true, fmt.Errorf("types section must define at least one type")
|
||||
}
|
||||
|
||||
// validate each entry for unknown keys and convert to TypeDef
|
||||
defs := make([]workflow.TypeDef, 0, len(typesSlice))
|
||||
for i, entry := range typesSlice {
|
||||
entryMap, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, true, fmt.Errorf("type at index %d: expected mapping, got %T", i, entry)
|
||||
}
|
||||
for k := range entryMap {
|
||||
if !validTypeDefKeys[k] {
|
||||
return nil, true, fmt.Errorf("type at index %d: unknown key %q (valid keys: key, label, emoji)", i, k)
|
||||
}
|
||||
}
|
||||
|
||||
var def workflow.TypeDef
|
||||
keyRaw, _ := entryMap["key"].(string)
|
||||
def.Key = workflow.TaskType(keyRaw)
|
||||
def.Label, _ = entryMap["label"].(string)
|
||||
def.Emoji, _ = entryMap["emoji"].(string)
|
||||
defs = append(defs, def)
|
||||
}
|
||||
|
||||
reg, err := workflow.NewTypeRegistry(defs)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return reg, true, nil
|
||||
}
|
||||
|
||||
// loadTypeRegistryFromFiles iterates workflow files. The last file that has a
|
||||
// types: key wins. Files without a types: key are skipped (no override).
|
||||
// If no file defines types:, returns a registry built from DefaultTypeDefs().
|
||||
func loadTypeRegistryFromFiles(files []string) (*workflow.TypeRegistry, string, error) {
|
||||
var lastReg *workflow.TypeRegistry
|
||||
var lastFile string
|
||||
|
||||
for _, path := range files {
|
||||
reg, present, err := loadTypesFromFile(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("loading types from %s: %w", path, err)
|
||||
}
|
||||
if present {
|
||||
lastReg = reg
|
||||
lastFile = path
|
||||
}
|
||||
}
|
||||
|
||||
if lastReg != nil {
|
||||
return lastReg, lastFile, nil
|
||||
}
|
||||
|
||||
// no file defined types: — fall back to built-in defaults
|
||||
reg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("building default type registry: %w", err)
|
||||
}
|
||||
return reg, "<built-in>", nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ func TestRegistry_Lookup(t *testing.T) {
|
|||
def, ok := reg.Lookup("ready")
|
||||
if !ok {
|
||||
t.Fatal("expected to find 'ready'")
|
||||
return
|
||||
}
|
||||
if def.Label != "Ready" {
|
||||
t.Errorf("expected label 'Ready', got %q", def.Label)
|
||||
|
|
@ -151,19 +152,32 @@ func TestRegistry_Keys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRegistry_NormalizesKeys(t *testing.T) {
|
||||
custom := []workflow.StatusDef{
|
||||
func TestRegistry_RejectsNonCanonicalKeys(t *testing.T) {
|
||||
_, err := workflow.NewStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "In-Progress", Label: "In Progress", Default: true},
|
||||
{Key: " DONE ", Label: "Done", Done: true},
|
||||
{Key: "done", Label: "Done", Done: true},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical key")
|
||||
}
|
||||
if got := err.Error(); got != `status key "In-Progress" is not canonical; use "inProgress"` {
|
||||
t.Errorf("got error %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_CanonicalKeysWork(t *testing.T) {
|
||||
custom := []workflow.StatusDef{
|
||||
{Key: "inProgress", Label: "In Progress", Default: true},
|
||||
{Key: "done", Label: "Done", Done: true},
|
||||
}
|
||||
setupTestRegistry(t, custom)
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
if !reg.IsValid("inProgress") {
|
||||
t.Error("expected 'inProgress' to be valid after normalization")
|
||||
t.Error("expected 'inProgress' to be valid")
|
||||
}
|
||||
if !reg.IsValid("done") {
|
||||
t.Error("expected 'done' to be valid after normalization")
|
||||
t.Error("expected 'done' to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,17 +209,87 @@ func TestBuildRegistry_Empty(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_DefaultFallsToFirst(t *testing.T) {
|
||||
func TestBuildRegistry_RequiresExplicitDefault(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha"},
|
||||
{Key: "alpha", Label: "Alpha", Done: true},
|
||||
{Key: "beta", Label: "Beta"},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no status is marked default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RequiresExplicitDone(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Default: true},
|
||||
{Key: "beta", Label: "Beta"},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no status is marked done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RejectsDuplicateDefault(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Default: true},
|
||||
{Key: "beta", Label: "Beta", Default: true, Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RejectsDuplicateDone(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Default: true, Done: true},
|
||||
{Key: "beta", Label: "Beta", Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RejectsDuplicateDisplay(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Open", Emoji: "🟢", Default: true},
|
||||
{Key: "beta", Label: "Open", Emoji: "🟢", Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate display")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_LabelDefaultsToKey(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Emoji: "🔵", Default: true},
|
||||
{Key: "beta", Emoji: "🔴", Done: true},
|
||||
}
|
||||
reg, err := workflow.NewStatusRegistry(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg.DefaultKey() != "alpha" {
|
||||
t.Errorf("expected default to fall back to first status 'alpha', got %q", reg.DefaultKey())
|
||||
def, ok := reg.Lookup("alpha")
|
||||
if !ok {
|
||||
t.Fatal("expected to find alpha")
|
||||
}
|
||||
if def.Label != "alpha" {
|
||||
t.Errorf("expected label to default to key, got %q", def.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_EmptyWhitespaceLabel(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: " ", Default: true},
|
||||
{Key: "beta", Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for whitespace-only label")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +513,9 @@ statuses:
|
|||
- key: alpha
|
||||
label: Alpha
|
||||
default: true
|
||||
- key: beta
|
||||
label: Beta
|
||||
done: true
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
statuses: [[[invalid yaml
|
||||
|
|
@ -614,3 +701,240 @@ func TestLoadStatusRegistryFromFiles_AllFilesEmpty(t *testing.T) {
|
|||
t.Errorf("expected empty path, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
// --- type loading tests ---
|
||||
|
||||
func TestLoadTypesFromFile_HappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
`)
|
||||
reg, present, err := loadTypesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !present {
|
||||
t.Fatal("expected present=true")
|
||||
}
|
||||
if reg == nil {
|
||||
t.Fatal("expected non-nil registry")
|
||||
}
|
||||
if !reg.IsValid("story") {
|
||||
t.Error("expected story to be valid")
|
||||
}
|
||||
if !reg.IsValid("bug") {
|
||||
t.Error("expected bug to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_Absent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
`)
|
||||
reg, present, err := loadTypesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if present {
|
||||
t.Error("expected present=false when types: key is absent")
|
||||
}
|
||||
if reg != nil {
|
||||
t.Error("expected nil registry when absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_EmptyList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `types: []`)
|
||||
_, present, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for types: []")
|
||||
}
|
||||
if !present {
|
||||
t.Error("expected present=true even for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_NonCanonicalKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: Story
|
||||
label: Story
|
||||
`)
|
||||
_, _, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_UnknownKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
aliases:
|
||||
- feature
|
||||
`)
|
||||
_, _, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key 'aliases'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_MissingLabelDefaultsToKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: task
|
||||
emoji: "📋"
|
||||
`)
|
||||
reg, _, err := loadTypesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := reg.TypeLabel("task"); got != "task" {
|
||||
t.Errorf("expected label to default to key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `types: [[[invalid`)
|
||||
_, _, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_LastFileWithTypesWins(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
types:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
types:
|
||||
- key: beta
|
||||
label: Beta
|
||||
- key: gamma
|
||||
label: Gamma
|
||||
`)
|
||||
|
||||
reg, path, err := loadTypeRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path != f2 {
|
||||
t.Errorf("expected path %q, got %q", f2, path)
|
||||
}
|
||||
if reg.IsValid("alpha") {
|
||||
t.Error("expected alpha to NOT be valid (overridden)")
|
||||
}
|
||||
if !reg.IsValid("beta") {
|
||||
t.Error("expected beta to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_SkipsFilesWithoutTypes(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
types:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
`)
|
||||
|
||||
reg, path, err := loadTypeRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path != f1 {
|
||||
t.Errorf("expected path %q (file with types), got %q", f1, path)
|
||||
}
|
||||
if !reg.IsValid("alpha") {
|
||||
t.Error("expected alpha to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_FallbackToBuiltins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
`)
|
||||
|
||||
reg, path, err := loadTypeRegistryFromFiles([]string{f})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path != "<built-in>" {
|
||||
t.Errorf("expected built-in path, got %q", path)
|
||||
}
|
||||
if !reg.IsValid("story") {
|
||||
t.Error("expected built-in story type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_ParseErrorStops(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: Story
|
||||
label: Story
|
||||
`)
|
||||
_, _, err := loadTypeRegistryFromFiles([]string{f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetTypeRegistry(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
|
||||
custom := []workflow.TypeDef{
|
||||
{Key: "task", Label: "Task"},
|
||||
{Key: "incident", Label: "Incident"},
|
||||
}
|
||||
ResetTypeRegistry(custom)
|
||||
|
||||
reg := GetTypeRegistry()
|
||||
if !reg.IsValid("task") {
|
||||
t.Error("expected 'task' to be valid after ResetTypeRegistry")
|
||||
}
|
||||
if reg.IsValid("story") {
|
||||
t.Error("expected 'story' to NOT be valid after ResetTypeRegistry")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
config/system.go
119
config/system.go
|
|
@ -3,9 +3,11 @@ package config
|
|||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
|
|
@ -56,8 +58,35 @@ func GenerateRandomID() string {
|
|||
return id
|
||||
}
|
||||
|
||||
// sampleFrontmatterRe extracts type and status values from sample tiki frontmatter.
|
||||
var sampleFrontmatterRe = regexp.MustCompile(`(?m)^(type|status):\s*(.+)$`)
|
||||
|
||||
// validateSampleTiki checks whether a sample tiki's type and status
|
||||
// are valid against the current workflow registries.
|
||||
func validateSampleTiki(template string) bool {
|
||||
matches := sampleFrontmatterRe.FindAllStringSubmatch(template, -1)
|
||||
statusReg := GetStatusRegistry()
|
||||
typeReg := GetTypeRegistry()
|
||||
for _, m := range matches {
|
||||
key, val := m[1], strings.TrimSpace(m[2])
|
||||
switch key {
|
||||
case "type":
|
||||
if _, ok := typeReg.ParseType(val); !ok {
|
||||
return false
|
||||
}
|
||||
case "status":
|
||||
if !statusReg.IsValid(val) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BootstrapSystem creates the task storage and seeds the initial tiki.
|
||||
func BootstrapSystem() error {
|
||||
// If createSamples is true, embedded sample tikis are validated against
|
||||
// the active workflow registries and only valid ones are written.
|
||||
func BootstrapSystem(createSamples bool) error {
|
||||
// Create all necessary directories
|
||||
if err := EnsureDirs(); err != nil {
|
||||
return fmt.Errorf("ensure directories: %w", err)
|
||||
|
|
@ -66,59 +95,50 @@ func BootstrapSystem() error {
|
|||
taskDir := GetTaskDir()
|
||||
var createdFiles []string
|
||||
|
||||
// Helper function to create a sample tiki
|
||||
createSampleTiki := func(template string) (string, error) {
|
||||
randomID := GenerateRandomID()
|
||||
taskID := fmt.Sprintf("TIKI-%s", randomID)
|
||||
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
|
||||
taskPath := filepath.Join(taskDir, taskFilename)
|
||||
|
||||
// Replace placeholder in template
|
||||
taskContent := strings.Replace(template, "TIKI-XXXXXX", taskID, 1)
|
||||
if err := os.WriteFile(taskPath, []byte(taskContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("write task: %w", err)
|
||||
if createSamples {
|
||||
// ensure workflow registries are loaded before validating samples;
|
||||
// on first run this may require installing the default workflow first
|
||||
if err := InstallDefaultWorkflow(); err != nil {
|
||||
slog.Warn("failed to install default workflow for sample validation", "error", err)
|
||||
}
|
||||
if err := LoadWorkflowRegistries(); err != nil {
|
||||
slog.Warn("failed to load workflow registries for sample validation; skipping samples", "error", err)
|
||||
createSamples = false
|
||||
}
|
||||
return taskPath, nil
|
||||
}
|
||||
|
||||
// Create board sample (original welcome tiki)
|
||||
boardPath, err := createSampleTiki(initialTaskTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create board sample: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, boardPath)
|
||||
if createSamples {
|
||||
type sampleDef struct {
|
||||
name string
|
||||
template string
|
||||
}
|
||||
samples := []sampleDef{
|
||||
{"board", initialTaskTemplate},
|
||||
{"backlog 1", backlogSample1},
|
||||
{"backlog 2", backlogSample2},
|
||||
{"roadmap now", roadmapNowSample},
|
||||
{"roadmap next", roadmapNextSample},
|
||||
{"roadmap later", roadmapLaterSample},
|
||||
}
|
||||
|
||||
// Create backlog samples
|
||||
backlog1Path, err := createSampleTiki(backlogSample1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create backlog sample 1: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, backlog1Path)
|
||||
for _, s := range samples {
|
||||
if !validateSampleTiki(s.template) {
|
||||
slog.Info("skipping incompatible sample tiki", "name", s.name)
|
||||
continue
|
||||
}
|
||||
|
||||
backlog2Path, err := createSampleTiki(backlogSample2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create backlog sample 2: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, backlog2Path)
|
||||
randomID := GenerateRandomID()
|
||||
taskID := fmt.Sprintf("TIKI-%s", randomID)
|
||||
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
|
||||
taskPath := filepath.Join(taskDir, taskFilename)
|
||||
|
||||
// Create roadmap samples
|
||||
roadmapNowPath, err := createSampleTiki(roadmapNowSample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roadmap now sample: %w", err)
|
||||
taskContent := strings.Replace(s.template, "TIKI-XXXXXX", taskID, 1)
|
||||
if err := os.WriteFile(taskPath, []byte(taskContent), 0644); err != nil {
|
||||
return fmt.Errorf("create sample %s: %w", s.name, err)
|
||||
}
|
||||
createdFiles = append(createdFiles, taskPath)
|
||||
}
|
||||
}
|
||||
createdFiles = append(createdFiles, roadmapNowPath)
|
||||
|
||||
roadmapNextPath, err := createSampleTiki(roadmapNextSample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roadmap next sample: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, roadmapNextPath)
|
||||
|
||||
roadmapLaterPath, err := createSampleTiki(roadmapLaterSample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roadmap later sample: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, roadmapLaterPath)
|
||||
|
||||
// Write doki documentation files
|
||||
dokiDir := GetDokiDir()
|
||||
|
|
@ -165,6 +185,11 @@ func GetDefaultNewTaskTemplate() string {
|
|||
return defaultNewTaskTemplate
|
||||
}
|
||||
|
||||
// GetDefaultWorkflowYAML returns the embedded default workflow.yaml content
|
||||
func GetDefaultWorkflowYAML() string {
|
||||
return defaultWorkflowYAML
|
||||
}
|
||||
|
||||
// InstallDefaultWorkflow installs the default workflow.yaml to the user config directory
|
||||
// if it does not already exist. This runs on every launch to handle first-run and upgrade cases.
|
||||
func InstallDefaultWorkflow() error {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const (
|
|||
ActionRefresh ActionID = "refresh"
|
||||
ActionToggleViewMode ActionID = "toggle_view_mode"
|
||||
ActionToggleHeader ActionID = "toggle_header"
|
||||
ActionOpenPalette ActionID = "open_palette"
|
||||
)
|
||||
|
||||
// ActionID values for task navigation and manipulation (used by plugins).
|
||||
|
|
@ -92,6 +93,7 @@ func InitPluginActions(plugins []PluginInfo) {
|
|||
if p.Key == 0 && p.Rune == 0 {
|
||||
continue // skip plugins without key binding
|
||||
}
|
||||
pluginViewID := model.MakePluginViewID(p.Name)
|
||||
pluginActionRegistry.Register(Action{
|
||||
ID: ActionID("plugin:" + p.Name),
|
||||
Key: p.Key,
|
||||
|
|
@ -99,6 +101,12 @@ func InitPluginActions(plugins []PluginInfo) {
|
|||
Modifier: p.Modifier,
|
||||
Label: p.Name,
|
||||
ShowInHeader: true,
|
||||
IsEnabled: func(view *ViewEntry, _ View) bool {
|
||||
if view == nil {
|
||||
return true
|
||||
}
|
||||
return view.ViewID != pluginViewID
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -124,12 +132,14 @@ func GetPluginNameFromAction(id ActionID) string {
|
|||
|
||||
// Action represents a keyboard shortcut binding
|
||||
type Action struct {
|
||||
ID ActionID
|
||||
Key tcell.Key
|
||||
Rune rune // for letter keys (when Key == tcell.KeyRune)
|
||||
Label string
|
||||
Modifier tcell.ModMask
|
||||
ShowInHeader bool // whether to display in header bar
|
||||
ID ActionID
|
||||
Key tcell.Key
|
||||
Rune rune // for letter keys (when Key == tcell.KeyRune)
|
||||
Label string
|
||||
Modifier tcell.ModMask
|
||||
ShowInHeader bool // whether to display in header bar
|
||||
HideFromPalette bool // when true, action is excluded from the action palette (zero value = visible)
|
||||
IsEnabled func(view *ViewEntry, activeView View) bool
|
||||
}
|
||||
|
||||
// keyWithMod is a composite map key for special-key lookups, disambiguating
|
||||
|
|
@ -246,13 +256,23 @@ func (r *ActionRegistry) Match(event *tcell.EventKey) *Action {
|
|||
return nil
|
||||
}
|
||||
|
||||
// selectionRequired is an IsEnabled predicate that returns true only when
|
||||
// the active view has a non-empty selection (for use with plugin/deps actions).
|
||||
func selectionRequired(_ *ViewEntry, activeView View) bool {
|
||||
if sv, ok := activeView.(SelectableView); ok {
|
||||
return sv.GetSelectedID() != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultGlobalActions returns common actions available in all views
|
||||
func DefaultGlobalActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Hide Header", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyCtrlA, Modifier: tcell.ModCtrl, Label: "All", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -267,6 +287,39 @@ func (r *ActionRegistry) GetHeaderActions() []Action {
|
|||
return result
|
||||
}
|
||||
|
||||
// GetPaletteActions returns palette-visible actions, deduped by ActionID (first registration wins).
|
||||
func (r *ActionRegistry) GetPaletteActions() []Action {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[ActionID]bool)
|
||||
var result []Action
|
||||
for _, a := range r.actions {
|
||||
if a.HideFromPalette {
|
||||
continue
|
||||
}
|
||||
if seen[a.ID] {
|
||||
continue
|
||||
}
|
||||
seen[a.ID] = true
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ContainsID returns true if the registry has an action with the given ID.
|
||||
func (r *ActionRegistry) ContainsID(id ActionID) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range r.actions {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToHeaderActions converts the registry's header actions to model.HeaderAction slice.
|
||||
// This bridges the controller→model boundary without requiring callers to do the mapping.
|
||||
func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
|
||||
|
|
@ -293,15 +346,22 @@ func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
|
|||
func TaskDetailViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true})
|
||||
taskDetailEnabled := func(view *ViewEntry, _ View) bool {
|
||||
if view == nil || view.ViewID != model.TaskDetailViewID {
|
||||
return false
|
||||
}
|
||||
return model.DecodeTaskDetailParams(view.Params).TaskID != ""
|
||||
}
|
||||
|
||||
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
|
||||
if config.GetAIAgent() != "" {
|
||||
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
}
|
||||
|
||||
return r
|
||||
|
|
@ -321,8 +381,8 @@ func TaskEditViewActions() *ActionRegistry {
|
|||
r := NewActionRegistry()
|
||||
|
||||
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true, HideFromPalette: true})
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
@ -330,15 +390,15 @@ func TaskEditViewActions() *ActionRegistry {
|
|||
// CommonFieldNavigationActions returns actions available in all field editors (Tab/Shift-Tab navigation)
|
||||
func CommonFieldNavigationActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditTitleActions returns actions available when editing the title field
|
||||
func TaskEditTitleActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
|
||||
r.Merge(CommonFieldNavigationActions())
|
||||
return r
|
||||
|
|
@ -347,16 +407,16 @@ func TaskEditTitleActions() *ActionRegistry {
|
|||
// TaskEditStatusActions returns actions available when editing the status field
|
||||
func TaskEditStatusActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditTypeActions returns actions available when editing the type field
|
||||
func TaskEditTypeActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -370,8 +430,8 @@ func TaskEditPriorityActions() *ActionRegistry {
|
|||
// TaskEditAssigneeActions returns actions available when editing the assignee field
|
||||
func TaskEditAssigneeActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -385,19 +445,19 @@ func TaskEditPointsActions() *ActionRegistry {
|
|||
// TaskEditDueActions returns actions available when editing the due date field
|
||||
func TaskEditDueActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditRecurrenceActions returns actions available when editing the recurrence field
|
||||
func TaskEditRecurrenceActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -455,22 +515,22 @@ func GetActionsForField(field model.EditField) *ActionRegistry {
|
|||
func PluginViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
// navigation (not shown in header)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
|
||||
// navigation (not shown in header, hidden from palette)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
|
||||
|
||||
// plugin actions (shown in header)
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleViewMode, Key: tcell.KeyRune, Rune: 'v', Label: "View mode", ShowInHeader: true})
|
||||
|
||||
|
|
@ -484,24 +544,24 @@ func PluginViewActions() *ActionRegistry {
|
|||
func DepsViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
// navigation (not shown in header)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
|
||||
// navigation (not shown in header, hidden from palette)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
|
||||
|
||||
// move task between lanes (shown in header)
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
|
||||
// task actions
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
|
||||
// view mode and search
|
||||
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
|
||||
|
|
|
|||
|
|
@ -404,11 +404,11 @@ func TestDefaultGlobalActions(t *testing.T) {
|
|||
registry := DefaultGlobalActions()
|
||||
actions := registry.GetActions()
|
||||
|
||||
if len(actions) != 4 {
|
||||
t.Errorf("expected 4 global actions, got %d", len(actions))
|
||||
if len(actions) != 5 {
|
||||
t.Errorf("expected 5 global actions, got %d", len(actions))
|
||||
}
|
||||
|
||||
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader}
|
||||
expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader, ActionOpenPalette}
|
||||
for i, expected := range expectedActions {
|
||||
if i >= len(actions) {
|
||||
t.Errorf("missing action at index %d: want %v", i, expected)
|
||||
|
|
@ -417,8 +417,30 @@ func TestDefaultGlobalActions(t *testing.T) {
|
|||
if actions[i].ID != expected {
|
||||
t.Errorf("action at index %d: want %v, got %v", i, expected, actions[i].ID)
|
||||
}
|
||||
if !actions[i].ShowInHeader {
|
||||
t.Errorf("global action %v should have ShowInHeader=true", expected)
|
||||
}
|
||||
|
||||
// ActionOpenPalette should show in header with label "All" and use Ctrl+A binding
|
||||
for _, a := range actions {
|
||||
if a.ID == ActionOpenPalette {
|
||||
if !a.ShowInHeader {
|
||||
t.Error("ActionOpenPalette should have ShowInHeader=true")
|
||||
}
|
||||
if a.Label != "All" {
|
||||
t.Errorf("ActionOpenPalette label = %q, want %q", a.Label, "All")
|
||||
}
|
||||
if a.Key != tcell.KeyCtrlA {
|
||||
t.Errorf("ActionOpenPalette Key = %v, want KeyCtrlA", a.Key)
|
||||
}
|
||||
if a.Modifier != tcell.ModCtrl {
|
||||
t.Errorf("ActionOpenPalette Modifier = %v, want ModCtrl", a.Modifier)
|
||||
}
|
||||
if a.Rune != 0 {
|
||||
t.Errorf("ActionOpenPalette Rune = %v, want 0", a.Rune)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !a.ShowInHeader {
|
||||
t.Errorf("global action %v should have ShowInHeader=true", a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -630,3 +652,163 @@ func TestMatchWithModifiers(t *testing.T) {
|
|||
t.Error("M (no modifier) should not match action with Alt-M binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_HidesMarkedActions(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh"})
|
||||
|
||||
actions := r.GetPaletteActions()
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 palette actions, got %d", len(actions))
|
||||
}
|
||||
if actions[0].ID != ActionQuit {
|
||||
t.Errorf("expected first action to be Quit, got %v", actions[0].ID)
|
||||
}
|
||||
if actions[1].ID != ActionRefresh {
|
||||
t.Errorf("expected second action to be Refresh, got %v", actions[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_DedupsByActionID(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑ (vim)"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
|
||||
actions := r.GetPaletteActions()
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 deduped actions, got %d", len(actions))
|
||||
}
|
||||
if actions[0].ID != ActionNavUp {
|
||||
t.Errorf("expected first action to be NavUp, got %v", actions[0].ID)
|
||||
}
|
||||
if actions[0].Key != tcell.KeyUp {
|
||||
t.Error("dedup should keep first registered binding (arrow key), not vim key")
|
||||
}
|
||||
if actions[1].ID != ActionNavDown {
|
||||
t.Errorf("expected second action to be NavDown, got %v", actions[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_NilRegistry(t *testing.T) {
|
||||
var r *ActionRegistry
|
||||
if actions := r.GetPaletteActions(); actions != nil {
|
||||
t.Errorf("expected nil from nil registry, got %v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsID(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back"})
|
||||
|
||||
if !r.ContainsID(ActionQuit) {
|
||||
t.Error("expected ContainsID to find ActionQuit")
|
||||
}
|
||||
if !r.ContainsID(ActionBack) {
|
||||
t.Error("expected ContainsID to find ActionBack")
|
||||
}
|
||||
if r.ContainsID(ActionRefresh) {
|
||||
t.Error("expected ContainsID to not find ActionRefresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsID_NilRegistry(t *testing.T) {
|
||||
var r *ActionRegistry
|
||||
if r.ContainsID(ActionQuit) {
|
||||
t.Error("expected false from nil registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGlobalActions_BackHiddenFromPalette(t *testing.T) {
|
||||
registry := DefaultGlobalActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
for _, a := range paletteActions {
|
||||
if a.ID == ActionBack {
|
||||
t.Error("ActionBack should be hidden from palette")
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.ContainsID(ActionBack) {
|
||||
t.Error("ActionBack should still be registered in global actions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginViewActions_NavHiddenFromPalette(t *testing.T) {
|
||||
registry := PluginViewActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
navIDs := map[ActionID]bool{
|
||||
ActionNavUp: true, ActionNavDown: true,
|
||||
ActionNavLeft: true, ActionNavRight: true,
|
||||
}
|
||||
for _, a := range paletteActions {
|
||||
if navIDs[a.ID] {
|
||||
t.Errorf("navigation action %v should be hidden from palette", a.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// semantic actions should remain visible
|
||||
found := map[ActionID]bool{}
|
||||
for _, a := range paletteActions {
|
||||
found[a.ID] = true
|
||||
}
|
||||
for _, want := range []ActionID{ActionOpenFromPlugin, ActionNewTask, ActionDeleteTask, ActionSearch} {
|
||||
if !found[want] {
|
||||
t.Errorf("expected palette-visible action %v", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEditActions_FieldLocalHidden_SaveVisible(t *testing.T) {
|
||||
registry := TaskEditTitleActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
found := map[ActionID]bool{}
|
||||
for _, a := range paletteActions {
|
||||
found[a.ID] = true
|
||||
}
|
||||
|
||||
if !found[ActionSaveTask] {
|
||||
t.Error("Save should be palette-visible in task edit")
|
||||
}
|
||||
if found[ActionQuickSave] {
|
||||
t.Error("Quick Save should be hidden from palette")
|
||||
}
|
||||
if found[ActionNextField] {
|
||||
t.Error("Next field should be hidden from palette")
|
||||
}
|
||||
if found[ActionPrevField] {
|
||||
t.Error("Prev field should be hidden from palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitPluginActions_ActivePluginDisabled(t *testing.T) {
|
||||
InitPluginActions([]PluginInfo{
|
||||
{Name: "Kanban", Key: tcell.KeyRune, Rune: '1'},
|
||||
{Name: "Backlog", Key: tcell.KeyRune, Rune: '2'},
|
||||
})
|
||||
|
||||
registry := GetPluginActions()
|
||||
actions := registry.GetPaletteActions()
|
||||
|
||||
kanbanViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Kanban")}
|
||||
backlogViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Backlog")}
|
||||
|
||||
for _, a := range actions {
|
||||
if a.ID == "plugin:Kanban" {
|
||||
if a.IsEnabled == nil {
|
||||
t.Fatal("expected IsEnabled on plugin:Kanban")
|
||||
}
|
||||
if a.IsEnabled(kanbanViewEntry, nil) {
|
||||
t.Error("Kanban activation should be disabled when Kanban view is active")
|
||||
}
|
||||
if !a.IsEnabled(backlogViewEntry, nil) {
|
||||
t.Error("Kanban activation should be enabled when Backlog view is active")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package controller
|
|||
import (
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// DokiController handles doki plugin view actions (documentation/markdown navigation).
|
||||
|
|
@ -59,6 +60,14 @@ func (dc *DokiController) HandleAction(actionID ActionID) bool {
|
|||
}
|
||||
|
||||
// HandleSearch is not applicable for DokiPlugins (documentation views don't have search)
|
||||
func (dc *DokiController) HandleSearch(query string) {
|
||||
// No-op: Doki plugins don't support search
|
||||
func (dc *DokiController) HandleSearch(query string) {}
|
||||
|
||||
func (dc *DokiController) GetActionInputSpec(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (dc *DokiController) CanStartActionInput(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (dc *DokiController) HandleActionInput(ActionID, string) InputSubmitResult {
|
||||
return InputKeepEditing
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ type PluginControllerInterface interface {
|
|||
HandleAction(ActionID) bool
|
||||
HandleSearch(string)
|
||||
ShowNavigation() bool
|
||||
GetActionInputSpec(ActionID) (prompt string, typ ruki.ValueType, hasInput bool)
|
||||
CanStartActionInput(ActionID) (prompt string, typ ruki.ValueType, ok bool)
|
||||
HandleActionInput(ActionID, string) InputSubmitResult
|
||||
}
|
||||
|
||||
// TikiViewProvider is implemented by controllers that back a TikiPlugin view.
|
||||
|
|
@ -54,6 +57,8 @@ type InputRouter struct {
|
|||
statusline *model.StatuslineConfig
|
||||
schema ruki.Schema
|
||||
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
|
||||
headerConfig *model.HeaderConfig
|
||||
paletteConfig *model.ActionPaletteConfig
|
||||
}
|
||||
|
||||
// NewInputRouter creates an input router
|
||||
|
|
@ -79,6 +84,16 @@ func NewInputRouter(
|
|||
}
|
||||
}
|
||||
|
||||
// SetHeaderConfig wires the header config for fullscreen-aware header toggling.
|
||||
func (ir *InputRouter) SetHeaderConfig(hc *model.HeaderConfig) {
|
||||
ir.headerConfig = hc
|
||||
}
|
||||
|
||||
// SetPaletteConfig wires the palette config for ActionOpenPalette dispatch.
|
||||
func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) {
|
||||
ir.paletteConfig = pc
|
||||
}
|
||||
|
||||
// HandleInput processes a key event for the current view and routes it to the appropriate handler.
|
||||
// It processes events through multiple handlers in order:
|
||||
// 1. Search input (if search is active)
|
||||
|
|
@ -91,6 +106,28 @@ func NewInputRouter(
|
|||
func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry) bool {
|
||||
slog.Debug("input received", "name", event.Name(), "key", int(event.Key()), "rune", string(event.Rune()), "modifiers", int(event.Modifiers()))
|
||||
|
||||
// palette fires regardless of focus context (Ctrl+A can't conflict with typing)
|
||||
if action := ir.globalActions.Match(event); action != nil {
|
||||
if action.ID == ActionOpenPalette {
|
||||
return ir.handleGlobalAction(action.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// if the input box is focused, let it handle all remaining input (including F10)
|
||||
if activeView := ir.navController.GetActiveView(); activeView != nil {
|
||||
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// pre-gate: global actions that must fire before task-edit Prepare() and before
|
||||
// search/fullscreen/editor gates
|
||||
if action := ir.globalActions.Match(event); action != nil {
|
||||
if action.ID == ActionToggleHeader {
|
||||
return ir.handleGlobalAction(action.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if currentView == nil {
|
||||
return false
|
||||
}
|
||||
|
|
@ -104,7 +141,7 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry
|
|||
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
|
||||
}
|
||||
|
||||
if stop, handled := ir.maybeHandleSearchInput(activeView, event); stop {
|
||||
if stop, handled := ir.maybeHandleInputBox(activeView, event); stop {
|
||||
return handled
|
||||
}
|
||||
if stop, handled := ir.maybeHandleFullscreenEscape(activeView, event); stop {
|
||||
|
|
@ -137,20 +174,20 @@ func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry
|
|||
}
|
||||
}
|
||||
|
||||
// maybeHandleSearchInput handles search box focus/visibility semantics.
|
||||
// maybeHandleInputBox handles input box focus/visibility semantics.
|
||||
// stop=true means input routing should stop and return handled.
|
||||
func (ir *InputRouter) maybeHandleSearchInput(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
|
||||
searchableView, ok := activeView.(SearchableView)
|
||||
func (ir *InputRouter) maybeHandleInputBox(activeView View, event *tcell.EventKey) (stop bool, handled bool) {
|
||||
inputableView, ok := activeView.(InputableView)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
if searchableView.IsSearchBoxFocused() {
|
||||
// Search box has focus and handles input through tview.
|
||||
if inputableView.IsInputBoxFocused() {
|
||||
return true, false
|
||||
}
|
||||
// Search is visible but grid has focus - handle Esc to close search.
|
||||
if searchableView.IsSearchVisible() && event.Key() == tcell.KeyEscape {
|
||||
searchableView.HideSearch()
|
||||
// visible but not focused (passive mode): Esc dismisses via cancel path
|
||||
// so search-specific teardown fires (clearing results)
|
||||
if inputableView.IsInputBoxVisible() && event.Key() == tcell.KeyEscape {
|
||||
inputableView.CancelInputBox()
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
|
|
@ -262,28 +299,29 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
|
|||
// handlePluginInput routes input to the appropriate plugin controller
|
||||
func (ir *InputRouter) handlePluginInput(event *tcell.EventKey, viewID model.ViewID) bool {
|
||||
pluginName := model.GetPluginName(viewID)
|
||||
controller, ok := ir.pluginControllers[pluginName]
|
||||
ctrl, ok := ir.pluginControllers[pluginName]
|
||||
if !ok {
|
||||
slog.Warn("plugin controller not found", "plugin", pluginName)
|
||||
return false
|
||||
}
|
||||
|
||||
registry := controller.GetActionRegistry()
|
||||
registry := ctrl.GetActionRegistry()
|
||||
if action := registry.Match(event); action != nil {
|
||||
// Handle search action specially - show search box
|
||||
if action.ID == ActionSearch {
|
||||
return ir.handleSearchAction(controller)
|
||||
return ir.handleSearchInput(ctrl)
|
||||
}
|
||||
// Handle plugin activation keys - switch to different plugin
|
||||
if targetPluginName := GetPluginNameFromAction(action.ID); targetPluginName != "" {
|
||||
targetViewID := model.MakePluginViewID(targetPluginName)
|
||||
if viewID != targetViewID {
|
||||
ir.navController.ReplaceView(targetViewID, nil)
|
||||
return true
|
||||
}
|
||||
return true // already on this plugin, consume the event
|
||||
return true
|
||||
}
|
||||
return controller.HandleAction(action.ID)
|
||||
if _, _, hasInput := ctrl.GetActionInputSpec(action.ID); hasInput {
|
||||
return ir.startActionInput(ctrl, action.ID)
|
||||
}
|
||||
return ctrl.HandleAction(action.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -293,8 +331,6 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
|
|||
switch actionID {
|
||||
case ActionBack:
|
||||
if v := ir.navController.GetActiveView(); v != nil && v.GetViewID() == model.TaskEditViewID {
|
||||
// Cancel edit session (discards changes) and close.
|
||||
// This keeps the ActionBack behavior consistent across input paths.
|
||||
return ir.taskEditCoord.CancelAndClose()
|
||||
}
|
||||
return ir.navController.HandleBack()
|
||||
|
|
@ -304,32 +340,262 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool {
|
|||
case ActionRefresh:
|
||||
_ = ir.taskStore.Reload()
|
||||
return true
|
||||
case ActionOpenPalette:
|
||||
if ir.paletteConfig != nil {
|
||||
ir.paletteConfig.SetVisible(true)
|
||||
}
|
||||
return true
|
||||
case ActionToggleHeader:
|
||||
ir.toggleHeader()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// handleSearchAction is a generic handler for ActionSearch across all searchable views
|
||||
func (ir *InputRouter) handleSearchAction(controller interface{ HandleSearch(string) }) bool {
|
||||
// toggleHeader toggles the stored user preference and recomputes effective visibility
|
||||
// against the live active view so fullscreen/header-hidden views stay force-hidden.
|
||||
func (ir *InputRouter) toggleHeader() {
|
||||
if ir.headerConfig == nil {
|
||||
return
|
||||
}
|
||||
newPref := !ir.headerConfig.GetUserPreference()
|
||||
ir.headerConfig.SetUserPreference(newPref)
|
||||
|
||||
visible := newPref
|
||||
if v := ir.navController.GetActiveView(); v != nil {
|
||||
if hv, ok := v.(interface{ RequiresHeaderHidden() bool }); ok && hv.RequiresHeaderHidden() {
|
||||
visible = false
|
||||
}
|
||||
if fv, ok := v.(FullscreenView); ok && fv.IsFullscreen() {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
ir.headerConfig.SetVisible(visible)
|
||||
}
|
||||
|
||||
// HandleAction dispatches a palette-selected action by ID against the given view entry.
|
||||
// This is the controller-side fallback for palette execution — the palette tries
|
||||
// view.HandlePaletteAction first, then falls back here.
|
||||
func (ir *InputRouter) HandleAction(id ActionID, currentView *ViewEntry) bool {
|
||||
if currentView == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// block palette-dispatched actions while an input box is in editing mode
|
||||
if activeView := ir.navController.GetActiveView(); activeView != nil {
|
||||
if iv, ok := activeView.(InputableView); ok && iv.IsInputBoxFocused() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// global actions
|
||||
if ir.globalActions.ContainsID(id) {
|
||||
return ir.handleGlobalAction(id)
|
||||
}
|
||||
|
||||
activeView := ir.navController.GetActiveView()
|
||||
searchableView, ok := activeView.(SearchableView)
|
||||
|
||||
switch currentView.ViewID {
|
||||
case model.TaskDetailViewID:
|
||||
taskID := model.DecodeTaskDetailParams(currentView.Params).TaskID
|
||||
if taskID != "" {
|
||||
ir.taskController.SetCurrentTask(taskID)
|
||||
}
|
||||
return ir.dispatchTaskAction(id, currentView.Params)
|
||||
|
||||
case model.TaskEditViewID:
|
||||
if activeView != nil {
|
||||
ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params))
|
||||
}
|
||||
return ir.dispatchTaskEditAction(id, activeView)
|
||||
|
||||
default:
|
||||
if model.IsPluginViewID(currentView.ViewID) {
|
||||
return ir.dispatchPluginAction(id, currentView.ViewID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTaskAction handles palette-dispatched task detail actions by ActionID.
|
||||
func (ir *InputRouter) dispatchTaskAction(id ActionID, _ map[string]interface{}) bool {
|
||||
switch id {
|
||||
case ActionEditTitle:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: taskID,
|
||||
Focus: model.EditFieldTitle,
|
||||
}))
|
||||
return true
|
||||
case ActionFullscreen:
|
||||
activeView := ir.navController.GetActiveView()
|
||||
if fullscreenView, ok := activeView.(FullscreenView); ok {
|
||||
if fullscreenView.IsFullscreen() {
|
||||
fullscreenView.ExitFullscreen()
|
||||
} else {
|
||||
fullscreenView.EnterFullscreen()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case ActionEditDesc:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: taskID,
|
||||
Focus: model.EditFieldDescription,
|
||||
DescOnly: true,
|
||||
}))
|
||||
return true
|
||||
case ActionEditTags:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: taskID,
|
||||
TagsOnly: true,
|
||||
}))
|
||||
return true
|
||||
case ActionEditDeps:
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
return ir.openDepsEditor(taskID)
|
||||
case ActionChat:
|
||||
agent := config.GetAIAgent()
|
||||
if agent == "" {
|
||||
return false
|
||||
}
|
||||
taskID := ir.taskController.GetCurrentTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
filename := strings.ToLower(taskID) + ".md"
|
||||
taskFilePath := filepath.Join(config.GetTaskDir(), filename)
|
||||
name, args := resolveAgentCommand(agent, taskFilePath)
|
||||
ir.navController.SuspendAndRun(name, args...)
|
||||
_ = ir.taskStore.ReloadTask(taskID)
|
||||
return true
|
||||
case ActionCloneTask:
|
||||
return ir.taskController.HandleAction(id)
|
||||
default:
|
||||
return ir.taskController.HandleAction(id)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTaskEditAction handles palette-dispatched task edit actions by ActionID.
|
||||
func (ir *InputRouter) dispatchTaskEditAction(id ActionID, activeView View) bool {
|
||||
switch id {
|
||||
case ActionSaveTask:
|
||||
if activeView != nil {
|
||||
return ir.taskEditCoord.CommitAndClose(activeView)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchPluginAction handles palette-dispatched plugin actions by ActionID.
|
||||
func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bool {
|
||||
if targetPluginName := GetPluginNameFromAction(id); targetPluginName != "" {
|
||||
targetViewID := model.MakePluginViewID(targetPluginName)
|
||||
if viewID != targetViewID {
|
||||
ir.navController.ReplaceView(targetViewID, nil)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
pluginName := model.GetPluginName(viewID)
|
||||
ctrl, ok := ir.pluginControllers[pluginName]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if id == ActionSearch {
|
||||
return ir.handleSearchInput(ctrl)
|
||||
}
|
||||
|
||||
if _, _, hasInput := ctrl.GetActionInputSpec(id); hasInput {
|
||||
return ir.startActionInput(ctrl, id)
|
||||
}
|
||||
|
||||
return ctrl.HandleAction(id)
|
||||
}
|
||||
|
||||
// startActionInput opens the input box for an action that requires user input.
|
||||
func (ir *InputRouter) startActionInput(ctrl PluginControllerInterface, actionID ActionID) bool {
|
||||
_, _, ok := ctrl.CanStartActionInput(actionID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
activeView := ir.navController.GetActiveView()
|
||||
inputableView, ok := activeView.(InputableView)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Set up focus callback
|
||||
app := ir.navController.GetApp()
|
||||
searchableView.SetFocusSetter(func(p tview.Primitive) {
|
||||
inputableView.SetFocusSetter(func(p tview.Primitive) {
|
||||
app.SetFocus(p)
|
||||
})
|
||||
|
||||
// Wire up search submit handler to controller
|
||||
searchableView.SetSearchSubmitHandler(controller.HandleSearch)
|
||||
inputableView.SetInputSubmitHandler(func(text string) InputSubmitResult {
|
||||
return ctrl.HandleActionInput(actionID, text)
|
||||
})
|
||||
|
||||
// Show search box and focus it
|
||||
searchBox := searchableView.ShowSearch()
|
||||
if searchBox != nil {
|
||||
app.SetFocus(searchBox)
|
||||
inputableView.SetInputCancelHandler(func() {
|
||||
inputableView.CancelInputBox()
|
||||
})
|
||||
|
||||
inputBox := inputableView.ShowInputBox("> ", "")
|
||||
if inputBox != nil {
|
||||
app.SetFocus(inputBox)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleSearchInput opens the input box in search mode for the active view.
|
||||
// Blocked when search is already passive — user must Esc first.
|
||||
func (ir *InputRouter) handleSearchInput(ctrl interface{ HandleSearch(string) }) bool {
|
||||
activeView := ir.navController.GetActiveView()
|
||||
inputableView, ok := activeView.(InputableView)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if inputableView.IsSearchPassive() {
|
||||
return true
|
||||
}
|
||||
|
||||
app := ir.navController.GetApp()
|
||||
inputableView.SetFocusSetter(func(p tview.Primitive) {
|
||||
app.SetFocus(p)
|
||||
})
|
||||
|
||||
inputableView.SetInputSubmitHandler(func(text string) InputSubmitResult {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return InputKeepEditing
|
||||
}
|
||||
ctrl.HandleSearch(trimmed)
|
||||
return InputShowPassive
|
||||
})
|
||||
|
||||
inputBox := inputableView.ShowSearchBox()
|
||||
if inputBox != nil {
|
||||
app.SetFocus(inputBox)
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -51,24 +51,46 @@ type SelectableView interface {
|
|||
SetSelectedID(id string)
|
||||
}
|
||||
|
||||
// SearchableView is a view that supports search functionality
|
||||
type SearchableView interface {
|
||||
// InputSubmitResult controls what happens to the input box after a submit callback.
|
||||
type InputSubmitResult int
|
||||
|
||||
const (
|
||||
InputKeepEditing InputSubmitResult = iota // keep the box open and focused for correction
|
||||
InputShowPassive // keep the box visible but unfocused/non-editable
|
||||
InputClose // close/hide the box
|
||||
)
|
||||
|
||||
// InputableView is a view that supports an input box (for search, action input, etc.)
|
||||
type InputableView interface {
|
||||
View
|
||||
|
||||
// ShowSearch displays the search box and returns the primitive to focus
|
||||
ShowSearch() tview.Primitive
|
||||
// ShowInputBox displays the input box for action-input mode.
|
||||
// If search is passive, it temporarily replaces the search indicator.
|
||||
ShowInputBox(prompt, initial string) tview.Primitive
|
||||
|
||||
// HideSearch hides the search box
|
||||
HideSearch()
|
||||
// ShowSearchBox opens the input box in search-editing mode.
|
||||
ShowSearchBox() tview.Primitive
|
||||
|
||||
// IsSearchVisible returns whether the search box is currently visible
|
||||
IsSearchVisible() bool
|
||||
// HideInputBox hides the input box (generic widget teardown only, no search state)
|
||||
HideInputBox()
|
||||
|
||||
// IsSearchBoxFocused returns whether the search box currently has focus
|
||||
IsSearchBoxFocused() bool
|
||||
// CancelInputBox triggers mode-aware cancel (search clears results, action-input restores passive search)
|
||||
CancelInputBox()
|
||||
|
||||
// SetSearchSubmitHandler sets the callback for when search is submitted
|
||||
SetSearchSubmitHandler(handler func(text string))
|
||||
// IsInputBoxVisible returns whether the input box is currently visible (any mode)
|
||||
IsInputBoxVisible() bool
|
||||
|
||||
// IsInputBoxFocused returns whether the input box currently has focus
|
||||
IsInputBoxFocused() bool
|
||||
|
||||
// IsSearchPassive returns true if search is applied and the box is in passive/unfocused mode
|
||||
IsSearchPassive() bool
|
||||
|
||||
// SetInputSubmitHandler sets the callback for when input is submitted
|
||||
SetInputSubmitHandler(handler func(text string) InputSubmitResult)
|
||||
|
||||
// SetInputCancelHandler sets the callback for when input is cancelled
|
||||
SetInputCancelHandler(handler func())
|
||||
|
||||
// SetFocusSetter sets the callback for requesting focus changes
|
||||
SetFocusSetter(setter func(p tview.Primitive))
|
||||
|
|
@ -272,6 +294,27 @@ type RecurrencePartNavigable interface {
|
|||
IsRecurrenceValueFocused() bool
|
||||
}
|
||||
|
||||
// PaletteActionHandler is implemented by views that handle palette-dispatched actions
|
||||
// directly (e.g., DokiView replays navigation as synthetic key events).
|
||||
// The palette tries this before falling back to InputRouter.HandleAction.
|
||||
type PaletteActionHandler interface {
|
||||
HandlePaletteAction(id ActionID) bool
|
||||
}
|
||||
|
||||
// FocusRestorer is implemented by views that can recover focus after the palette closes
|
||||
// when the originally saved focused primitive is no longer valid (e.g., TaskDetailView
|
||||
// rebuilds its description primitive during store-driven refresh).
|
||||
type FocusRestorer interface {
|
||||
RestoreFocus() bool
|
||||
}
|
||||
|
||||
// ActionChangeNotifier is implemented by views that mutate their action registry
|
||||
// or live enablement/presentation state while the same view instance stays active.
|
||||
// RootLayout wires the handler and reruns syncViewContextFromView when fired.
|
||||
type ActionChangeNotifier interface {
|
||||
SetActionChangeHandler(handler func())
|
||||
}
|
||||
|
||||
// ViewInfoProvider is a view that provides its name and description for the header info section
|
||||
type ViewInfoProvider interface {
|
||||
GetViewName() string
|
||||
|
|
|
|||
|
|
@ -55,13 +55,17 @@ func NewPluginController(
|
|||
"plugin", pluginDef.Name, "key", string(a.Rune),
|
||||
"plugin_action", a.Label, "built_in_action", existing.Label)
|
||||
}
|
||||
pc.registry.Register(Action{
|
||||
action := Action{
|
||||
ID: pluginActionID(a.Rune),
|
||||
Key: tcell.KeyRune,
|
||||
Rune: a.Rune,
|
||||
Label: a.Label,
|
||||
ShowInHeader: true,
|
||||
})
|
||||
ShowInHeader: a.ShowInHeader,
|
||||
}
|
||||
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
|
||||
action.IsEnabled = selectionRequired
|
||||
}
|
||||
pc.registry.Register(action)
|
||||
}
|
||||
|
||||
return pc
|
||||
|
|
@ -139,29 +143,33 @@ func (pc *PluginController) HandleSearch(query string) {
|
|||
})
|
||||
}
|
||||
|
||||
// handlePluginAction applies a plugin shortcut action to the currently selected task.
|
||||
func (pc *PluginController) handlePluginAction(r rune) bool {
|
||||
// find the matching action definition
|
||||
var pa *plugin.PluginAction
|
||||
// getPluginAction looks up a plugin action by ActionID.
|
||||
func (pc *PluginController) getPluginAction(actionID ActionID) (*plugin.PluginAction, rune, bool) {
|
||||
r := getPluginActionRune(actionID)
|
||||
if r == 0 {
|
||||
return nil, 0, false
|
||||
}
|
||||
for i := range pc.pluginDef.Actions {
|
||||
if pc.pluginDef.Actions[i].Rune == r {
|
||||
pa = &pc.pluginDef.Actions[i]
|
||||
break
|
||||
return &pc.pluginDef.Actions[i], r, true
|
||||
}
|
||||
}
|
||||
if pa == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
executor := pc.newExecutor()
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
// buildExecutionInput builds the base ExecutionInput for an action, performing
|
||||
// selection/create-template preflight. Returns ok=false if the action can't run.
|
||||
func (pc *PluginController) buildExecutionInput(pa *plugin.PluginAction) (ruki.ExecutionInput, bool) {
|
||||
input := ruki.ExecutionInput{}
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
|
||||
if pa.Action.IsUpdate() || pa.Action.IsDelete() {
|
||||
if pa.Action.IsSelect() && !pa.Action.IsPipe() {
|
||||
if taskID != "" {
|
||||
input.SelectedTaskID = taskID
|
||||
}
|
||||
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
|
||||
if taskID == "" {
|
||||
return false
|
||||
return input, false
|
||||
}
|
||||
input.SelectedTaskID = taskID
|
||||
}
|
||||
|
|
@ -169,20 +177,35 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
if pa.Action.IsCreate() {
|
||||
template, err := pc.taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
|
||||
return false
|
||||
slog.Error("failed to create task template for plugin action", "error", err)
|
||||
return input, false
|
||||
}
|
||||
input.CreateTemplate = template
|
||||
}
|
||||
|
||||
return input, true
|
||||
}
|
||||
|
||||
// executeAndApply runs the executor and applies the result (store mutations, pipe, clipboard).
|
||||
func (pc *PluginController) executeAndApply(pa *plugin.PluginAction, input ruki.ExecutionInput, r rune) bool {
|
||||
executor := pc.newExecutor()
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
|
||||
result, err := executor.Execute(pa.Action, allTasks, input)
|
||||
if err != nil {
|
||||
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
slog.Error("failed to execute plugin action", "task_id", input.SelectedTaskID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
switch {
|
||||
case result.Select != nil:
|
||||
slog.Info("select plugin action executed", "task_id", input.SelectedTaskID, "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 {
|
||||
|
|
@ -212,12 +235,93 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
return false
|
||||
}
|
||||
}
|
||||
case result.Pipe != nil:
|
||||
for _, row := range result.Pipe.Rows {
|
||||
if err := service.ExecutePipeCommand(ctx, result.Pipe.Command, row); err != nil {
|
||||
slog.Error("pipe command failed", "command", result.Pipe.Command, "args", row, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
case result.Clipboard != nil:
|
||||
if err := service.ExecuteClipboardPipe(result.Clipboard.Rows); err != nil {
|
||||
slog.Error("clipboard pipe failed", "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage("copied to clipboard", model.MessageLevelInfo, true)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
slog.Info("plugin action applied", "task_id", input.SelectedTaskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
// handlePluginAction applies a plugin shortcut action to the currently selected task.
|
||||
func (pc *PluginController) handlePluginAction(r rune) bool {
|
||||
pa, _, ok := pc.getPluginAction(pluginActionID(r))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
input, ok := pc.buildExecutionInput(pa)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return pc.executeAndApply(pa, input, r)
|
||||
}
|
||||
|
||||
// GetActionInputSpec returns the prompt and input type for an action, if it has input.
|
||||
func (pc *PluginController) GetActionInputSpec(actionID ActionID) (string, ruki.ValueType, bool) {
|
||||
pa, _, ok := pc.getPluginAction(actionID)
|
||||
if !ok || !pa.HasInput {
|
||||
return "", 0, false
|
||||
}
|
||||
return pa.Label + ": ", pa.InputType, true
|
||||
}
|
||||
|
||||
// CanStartActionInput checks whether an input-backed action can currently run
|
||||
// (selection/create-template preflight passes).
|
||||
func (pc *PluginController) CanStartActionInput(actionID ActionID) (string, ruki.ValueType, bool) {
|
||||
pa, _, ok := pc.getPluginAction(actionID)
|
||||
if !ok || !pa.HasInput {
|
||||
return "", 0, false
|
||||
}
|
||||
if _, ok := pc.buildExecutionInput(pa); !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
return pa.Label + ": ", pa.InputType, true
|
||||
}
|
||||
|
||||
// HandleActionInput handles submitted text for an input-backed action.
|
||||
func (pc *PluginController) HandleActionInput(actionID ActionID, text string) InputSubmitResult {
|
||||
pa, r, ok := pc.getPluginAction(actionID)
|
||||
if !ok || !pa.HasInput {
|
||||
return InputKeepEditing
|
||||
}
|
||||
|
||||
val, err := ruki.ParseScalarValue(pa.InputType, text)
|
||||
if err != nil {
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return InputKeepEditing
|
||||
}
|
||||
|
||||
input, ok := pc.buildExecutionInput(pa)
|
||||
if !ok {
|
||||
return InputClose
|
||||
}
|
||||
input.InputValue = val
|
||||
input.HasInput = true
|
||||
|
||||
pc.executeAndApply(pa, input, r)
|
||||
return InputClose
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleMoveTask(offset int) bool {
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ func (pb *pluginBase) newExecutor() *ruki.Executor {
|
|||
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
|
||||
func (pb *pluginBase) GetPluginName() string { return pb.pluginDef.Name }
|
||||
|
||||
// default no-op implementations for input-backed action methods
|
||||
func (pb *pluginBase) GetActionInputSpec(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (pb *pluginBase) CanStartActionInput(ActionID) (string, ruki.ValueType, bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
func (pb *pluginBase) HandleActionInput(ActionID, string) InputSubmitResult { return InputKeepEditing }
|
||||
|
||||
func (pb *pluginBase) handleNav(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
lane := pb.pluginConfig.GetSelectedLane()
|
||||
tasks := filteredTasks(lane)
|
||||
|
|
|
|||
|
|
@ -1224,6 +1224,230 @@ 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 mustParseStmtWithInput(t *testing.T, input string, inputType ruki.ValueType) *ruki.ValidatedStatement {
|
||||
t.Helper()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
stmt, err := parser.ParseAndValidateStatementWithInput(input, ruki.ExecutorRuntimePlugin, inputType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ruki statement %q: %v", input, err)
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
|
||||
func TestPluginController_HandleActionInput_ValidInput(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"`)
|
||||
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
result := pc.HandleActionInput(pluginActionID('a'), "alice")
|
||||
if result != InputClose {
|
||||
t.Fatalf("expected InputClose for valid input, got %d", result)
|
||||
}
|
||||
|
||||
updated := taskStore.GetTask("T-1")
|
||||
if updated.Assignee != "alice" {
|
||||
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleActionInput_InvalidInput(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"`)
|
||||
pointsAction := mustParseStmtWithInput(t, `update where id = id() set points = input()`, ruki.ValueInt)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'p', Label: "Set points", Action: pointsAction, InputType: ruki.ValueInt, HasInput: true},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
result := pc.HandleActionInput(pluginActionID('p'), "abc")
|
||||
if result != InputKeepEditing {
|
||||
t.Fatalf("expected InputKeepEditing for invalid int input, got %d", result)
|
||||
}
|
||||
|
||||
msg, level, _ := statusline.GetMessage()
|
||||
if level != model.MessageLevelError {
|
||||
t.Fatalf("expected error message in statusline, got level %v msg %q", level, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleActionInput_ExecutionFailure_StillCloses(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
// no tasks in store — executor will find no match for id(), which means
|
||||
// the update produces no results (not an error), but executeAndApply still returns true.
|
||||
// Instead, test with a task that exists but use input on a field
|
||||
// where the assignment succeeds at parse/execution level.
|
||||
_ = 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"`)
|
||||
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
// valid parse, successful execution — still returns InputClose
|
||||
result := pc.HandleActionInput(pluginActionID('a'), "bob")
|
||||
if result != InputClose {
|
||||
t.Fatalf("expected InputClose after valid parse (regardless of execution outcome), got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetActionInputSpec(t *testing.T) {
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
assignAction := mustParseStmtWithInput(t, `update where id = id() set assignee = input()`, ruki.ValueString)
|
||||
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'a', Label: "Assign to...", Action: assignAction, InputType: ruki.ValueString, HasInput: true},
|
||||
{Rune: 'd', Label: "Done", Action: markDoneAction},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(store.NewInMemoryStore())
|
||||
pc := NewPluginController(store.NewInMemoryStore(), gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
prompt, typ, hasInput := pc.GetActionInputSpec(pluginActionID('a'))
|
||||
if !hasInput {
|
||||
t.Fatal("expected hasInput=true for 'a' action")
|
||||
}
|
||||
if typ != ruki.ValueString {
|
||||
t.Fatalf("expected ValueString, got %d", typ)
|
||||
}
|
||||
if prompt != "Assign to...: " {
|
||||
t.Fatalf("expected prompt 'Assign to...: ', got %q", prompt)
|
||||
}
|
||||
|
||||
_, _, hasInput = pc.GetActionInputSpec(pluginActionID('d'))
|
||||
if hasInput {
|
||||
t.Fatal("expected hasInput=false for non-input 'd' action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginActionRune(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -817,6 +817,7 @@ func TestTaskController_GetCurrentTask(t *testing.T) {
|
|||
current := tc.GetCurrentTask()
|
||||
if current == nil {
|
||||
t.Fatal("GetCurrentTask returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
if current.ID != original.ID {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ func TestTaskEditCoordinator_Commit_SavesTags(t *testing.T) {
|
|||
saved := taskStore.GetTask(draft.ID)
|
||||
if saved == nil {
|
||||
t.Fatal("task not found in store after commit")
|
||||
return
|
||||
}
|
||||
if len(saved.Tags) != 2 || saved.Tags[0] != "api" || saved.Tags[1] != "backend" {
|
||||
t.Errorf("saved tags = %v, want [api backend]", saved.Tags)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func TestNavigationState_PushPop(t *testing.T) {
|
|||
entry := nav.pop()
|
||||
if entry == nil {
|
||||
t.Fatal("pop() returned nil, want ViewEntry")
|
||||
return
|
||||
}
|
||||
if entry.ViewID != model.TaskDetailViewID {
|
||||
t.Errorf("ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
|
||||
|
|
@ -47,6 +48,7 @@ func TestNavigationState_PushPop(t *testing.T) {
|
|||
entry = nav.pop()
|
||||
if entry == nil {
|
||||
t.Fatal("pop() returned nil, want ViewEntry")
|
||||
return
|
||||
}
|
||||
if entry.ViewID != model.TaskDetailViewID {
|
||||
t.Errorf("ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
|
||||
|
|
@ -81,6 +83,7 @@ func TestNavigationState_CurrentView(t *testing.T) {
|
|||
entry = nav.currentView()
|
||||
if entry == nil {
|
||||
t.Fatal("currentView() returned nil")
|
||||
return
|
||||
}
|
||||
if entry.ViewID != model.TaskEditViewID {
|
||||
t.Errorf("ViewID = %v, want %v", entry.ViewID, model.TaskEditViewID)
|
||||
|
|
@ -139,6 +142,7 @@ func TestNavigationState_PreviousView(t *testing.T) {
|
|||
entry = nav.previousView()
|
||||
if entry == nil {
|
||||
t.Fatal("previousView() returned nil, want ViewEntry")
|
||||
return
|
||||
}
|
||||
if entry.ViewID != model.TaskDetailViewID {
|
||||
t.Errorf("previousView() ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
|
||||
|
|
@ -149,6 +153,7 @@ func TestNavigationState_PreviousView(t *testing.T) {
|
|||
entry = nav.previousView()
|
||||
if entry == nil {
|
||||
t.Fatal("previousView() returned nil")
|
||||
return
|
||||
}
|
||||
if entry.ViewID != model.TaskDetailViewID {
|
||||
t.Errorf("previousView() ViewID = %v, want %v", entry.ViewID, model.TaskDetailViewID)
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.0
|
|||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0
|
||||
github.com/alecthomas/participle/v2 v2.1.4
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/boolean-maybe/navidown v0.4.18
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
|
|
@ -25,7 +26,6 @@ require (
|
|||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
|
|
|
|||
164
integration/action_palette_test.go
Normal file
164
integration/action_palette_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/testutil"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestActionPalette_OpenAndClose(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
// Ctrl+A opens the palette
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be visible after pressing Ctrl+A")
|
||||
}
|
||||
|
||||
// Esc closes it
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
if ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be hidden after pressing Esc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPalette_F10TogglesHeader(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
hc := ta.GetHeaderConfig()
|
||||
initialVisible := hc.IsVisible()
|
||||
|
||||
// F10 should toggle header via the router
|
||||
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
|
||||
if hc.IsVisible() == initialVisible {
|
||||
t.Fatal("F10 should toggle header visibility")
|
||||
}
|
||||
|
||||
// toggle back
|
||||
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
|
||||
if hc.IsVisible() != initialVisible {
|
||||
t.Fatal("second F10 should restore header visibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPalette_ModalBlocksGlobals(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
hc := ta.GetHeaderConfig()
|
||||
startVisible := hc.IsVisible()
|
||||
|
||||
// open palette
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be open")
|
||||
}
|
||||
|
||||
// F10 while palette is open should NOT toggle header
|
||||
// (app capture returns event unchanged, palette input handler swallows F10)
|
||||
ta.SendKey(tcell.KeyF10, 0, tcell.ModNone)
|
||||
if hc.IsVisible() != startVisible {
|
||||
t.Fatal("F10 should be blocked while palette is modal")
|
||||
}
|
||||
|
||||
// close palette
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestActionPalette_AsteriskIsFilterTextInPalette(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
// open palette
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should be open")
|
||||
}
|
||||
|
||||
// typing '*' while palette is open should be treated as filter text, not open another palette
|
||||
ta.SendKeyToFocused(tcell.KeyRune, '*', tcell.ModNone)
|
||||
|
||||
// palette should still be open
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should remain open when '*' is typed as filter")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestActionPalette_AsteriskDoesNotOpenPalette(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
ta.Draw()
|
||||
|
||||
// send '*' as a rune on the plugin view
|
||||
ta.SendKey(tcell.KeyRune, '*', tcell.ModNone)
|
||||
|
||||
if ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should NOT open when '*' is pressed — only Ctrl+A should open it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPalette_OpensInTaskEdit(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", taskpkg.StatusReady, taskpkg.TypeStory); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
ta.NavController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: "TIKI-1",
|
||||
Focus: model.EditFieldTitle,
|
||||
}))
|
||||
ta.Draw()
|
||||
|
||||
// Ctrl+A should open the palette even in task edit
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should open when Ctrl+A is pressed in task edit view")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestActionPalette_OpensWithInputBoxFocused(t *testing.T) {
|
||||
ta := testutil.NewTestApp(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
|
||||
ta.Draw()
|
||||
|
||||
// open search to focus input box
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
|
||||
v := ta.NavController.GetActiveView()
|
||||
iv, ok := v.(controller.InputableView)
|
||||
if !ok || !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused after '/'")
|
||||
}
|
||||
|
||||
// Ctrl+A should open the palette even with input box focused
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ func TestTaskDetailView_ChatModifiesTask(t *testing.T) {
|
|||
updated := ta.TaskStore.GetTask(taskID)
|
||||
if updated == nil {
|
||||
t.Fatal("task not found after chat")
|
||||
return
|
||||
}
|
||||
if updated.Title != "AI Modified Title" {
|
||||
t.Errorf("title = %q, want %q", updated.Title, "AI Modified Title")
|
||||
|
|
@ -114,6 +115,7 @@ func TestTaskDetailView_ChatNotAvailableWithoutConfig(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "Unchanged Title" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "Unchanged Title")
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ func TestDepsEditor_MoveTask_AllToDepends_PersistsOnDisk(t *testing.T) {
|
|||
updated := ta.TaskStore.GetTask(contextID)
|
||||
if updated == nil {
|
||||
t.Fatalf("context task not found in store")
|
||||
return
|
||||
}
|
||||
if !slices.Contains(updated.DependsOn, freeID) {
|
||||
t.Errorf("DependsOn = %v, want it to contain %s", updated.DependsOn, freeID)
|
||||
|
|
@ -170,6 +171,7 @@ func TestDepsEditor_MoveTask_AllToDepends_PersistsOnDisk(t *testing.T) {
|
|||
reloaded := ta.TaskStore.GetTask(contextID)
|
||||
if reloaded == nil {
|
||||
t.Fatalf("context task not found after reload")
|
||||
return
|
||||
}
|
||||
if !slices.Contains(reloaded.DependsOn, freeID) {
|
||||
t.Errorf("after reload: DependsOn = %v, want it to contain %s", reloaded.DependsOn, freeID)
|
||||
|
|
@ -223,6 +225,7 @@ func TestDepsEditor_MoveTask_DependsToAll_RemovesDep(t *testing.T) {
|
|||
updated := ta.TaskStore.GetTask(contextID)
|
||||
if updated == nil {
|
||||
t.Fatalf("context task not found in store")
|
||||
return
|
||||
}
|
||||
if slices.Contains(updated.DependsOn, depID) {
|
||||
t.Errorf("DependsOn = %v, should not contain %s after removal", updated.DependsOn, depID)
|
||||
|
|
@ -235,6 +238,7 @@ func TestDepsEditor_MoveTask_DependsToAll_RemovesDep(t *testing.T) {
|
|||
reloaded := ta.TaskStore.GetTask(contextID)
|
||||
if reloaded == nil {
|
||||
t.Fatalf("context task not found after reload")
|
||||
return
|
||||
}
|
||||
if slices.Contains(reloaded.DependsOn, depID) {
|
||||
t.Errorf("after reload: DependsOn = %v, should not contain %s", reloaded.DependsOn, depID)
|
||||
|
|
|
|||
574
integration/input_action_test.go
Normal file
574
integration/input_action_test.go
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/testutil"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
const inputActionWorkflow = `views:
|
||||
plugins:
|
||||
- name: InputTest
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: All
|
||||
columns: 1
|
||||
filter: select where status = "backlog" order by id
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
- key: "p"
|
||||
label: "Set points"
|
||||
action: update where id = id() set points=input()
|
||||
input: int
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
`
|
||||
|
||||
func setupInputActionTest(t *testing.T) *testutil.TestApp {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(inputActionWorkflow), 0644); err != nil {
|
||||
t.Fatalf("failed to write workflow.yaml: %v", err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get cwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
ta := testutil.NewTestApp(t)
|
||||
if err := ta.LoadPlugins(); err != nil {
|
||||
t.Fatalf("failed to load plugins: %v", err)
|
||||
}
|
||||
|
||||
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test Task", task.StatusBacklog, task.TypeStory); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
|
||||
ta.NavController.PushView(model.MakePluginViewID("InputTest"), nil)
|
||||
ta.Draw()
|
||||
|
||||
return ta
|
||||
}
|
||||
|
||||
func getActiveInputableView(ta *testutil.TestApp) controller.InputableView {
|
||||
v := ta.NavController.GetActiveView()
|
||||
iv, _ := v.(controller.InputableView)
|
||||
return iv
|
||||
}
|
||||
|
||||
func TestInputAction_KeyOpensPrompt(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv == nil {
|
||||
t.Fatal("active view does not implement InputableView")
|
||||
}
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should not be visible initially")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be visible after pressing 'A'")
|
||||
}
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused after pressing 'A'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_EnterAppliesMutation(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
ta.SendText("alice")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after valid submit")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Assignee != "alice" {
|
||||
t.Fatalf("expected assignee=alice, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_EscCancelsWithoutMutation(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
ta.SendText("bob")
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after Esc")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Assignee != "" {
|
||||
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_NonInputActionStillWorks(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
// 'b' is a non-input action — should execute immediately without prompt
|
||||
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("non-input action should not open input box")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Status != task.StatusReady {
|
||||
t.Fatalf("expected status ready, got %v", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_ModalBlocksOtherActions(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
// open action-input prompt
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
iv := getActiveInputableView(ta)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused")
|
||||
}
|
||||
|
||||
// while modal, 'b' should NOT execute the non-input action
|
||||
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected status backlog (action should be blocked while modal), got %v", updated.Status)
|
||||
}
|
||||
|
||||
// cancel and verify box closes
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after Esc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_SearchPassiveBlocksNewSearch(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// open search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused after '/'")
|
||||
}
|
||||
|
||||
// type and submit search
|
||||
ta.SendText("Test")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// should be in passive mode: visible but not focused
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("search box should remain visible in passive mode")
|
||||
}
|
||||
if iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should not be focused in passive mode")
|
||||
}
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("expected search-passive state")
|
||||
}
|
||||
|
||||
// pressing '/' again should NOT re-enter search editing
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if iv.IsInputBoxFocused() {
|
||||
t.Fatal("'/' should be blocked while search is passive — user must Esc first")
|
||||
}
|
||||
|
||||
// Esc clears search and closes
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("input box should be hidden after Esc from passive mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_PassiveSearchReplacedByActionInput(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// set up passive search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
ta.SendText("Test")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("expected search-passive state")
|
||||
}
|
||||
|
||||
// action-input should temporarily replace the passive search
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("action-input should be focused, replacing passive search")
|
||||
}
|
||||
if iv.IsSearchPassive() {
|
||||
t.Fatal("should no longer be in search-passive while action-input is active")
|
||||
}
|
||||
|
||||
// submit action input
|
||||
ta.SendText("carol")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// should restore passive search
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("passive search should be restored after action-input closes")
|
||||
}
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("should be back in search-passive mode")
|
||||
}
|
||||
|
||||
// verify the mutation happened
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if updated.Assignee != "carol" {
|
||||
t.Fatalf("expected assignee=carol, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_ActionInputEscRestoresPassiveSearch(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// set up passive search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
ta.SendText("Test")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// open action-input
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
ta.SendText("dave")
|
||||
|
||||
// Esc should restore passive search, not clear it
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
|
||||
if !iv.IsSearchPassive() {
|
||||
t.Fatal("Esc from action-input should restore passive search")
|
||||
}
|
||||
|
||||
// verify no mutation
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Assignee != "" {
|
||||
t.Fatalf("expected empty assignee after cancel, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_SearchEditingBlocksPluginActions(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// open search
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused")
|
||||
}
|
||||
|
||||
// while search editing is active, 'b' (non-input action) should be blocked
|
||||
ta.SendKey(tcell.KeyRune, 'b', tcell.ModNone)
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected status backlog (action blocked during search editing), got %v", updated.Status)
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_EmptySearchEnterIsNoOp(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused")
|
||||
}
|
||||
|
||||
// Enter on empty text should keep editing open
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("empty search Enter should keep box focused (no-op)")
|
||||
}
|
||||
if iv.IsSearchPassive() {
|
||||
t.Fatal("empty search Enter should not transition to passive")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_PaletteOpensDuringModal(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
// open action-input
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
iv := getActiveInputableView(ta)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("input box should be focused")
|
||||
}
|
||||
|
||||
// Ctrl+A should open the palette even while input box is focused
|
||||
ta.SendKey(tcell.KeyCtrlA, 0, tcell.ModCtrl)
|
||||
if !ta.GetPaletteConfig().IsVisible() {
|
||||
t.Fatal("palette should open when Ctrl+A is pressed with input box focused")
|
||||
}
|
||||
|
||||
// clean up
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_PaletteDispatchOpensPrompt(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// simulate palette dispatch: call HandleAction directly with the input-backed action ID
|
||||
actionID := controller.ActionID("plugin_action:A")
|
||||
ta.InputRouter.HandleAction(actionID, ta.NavController.CurrentView())
|
||||
ta.Draw()
|
||||
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("palette-dispatched input action should open the prompt")
|
||||
}
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("prompt should be focused after palette dispatch")
|
||||
}
|
||||
|
||||
// type and submit
|
||||
ta.SendText("eve")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
if iv.IsInputBoxVisible() {
|
||||
t.Fatal("prompt should close after valid submit")
|
||||
}
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Assignee != "eve" {
|
||||
t.Fatalf("expected assignee=eve via palette dispatch, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_InvalidInputKeepsPromptOpen(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
originalTask := ta.TaskStore.GetTask("TIKI-1")
|
||||
originalPoints := originalTask.Points
|
||||
|
||||
// open int input (points)
|
||||
ta.SendKey(tcell.KeyRune, 'p', tcell.ModNone)
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("prompt should be focused")
|
||||
}
|
||||
|
||||
// type non-numeric text and submit
|
||||
ta.SendText("abc")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
// prompt should stay open — invalid input
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("prompt should remain focused after invalid input")
|
||||
}
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("prompt should remain visible after invalid input")
|
||||
}
|
||||
|
||||
// verify no mutation
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated.Points != originalPoints {
|
||||
t.Fatalf("expected points=%d (unchanged), got %d", originalPoints, updated.Points)
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_PreflightNoTaskSelected_NoPrompt(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// workflow with an empty lane (no tasks will match)
|
||||
workflow := `views:
|
||||
plugins:
|
||||
- name: EmptyTest
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Empty
|
||||
columns: 1
|
||||
filter: select where status = "nonexistent" order by id
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflow), 0644); err != nil {
|
||||
t.Fatalf("failed to write workflow.yaml: %v", err)
|
||||
}
|
||||
origDir, _ := os.Getwd()
|
||||
_ = os.Chdir(tmpDir)
|
||||
t.Cleanup(func() { _ = os.Chdir(origDir) })
|
||||
|
||||
ta := testutil.NewTestApp(t)
|
||||
if err := ta.LoadPlugins(); err != nil {
|
||||
t.Fatalf("failed to load plugins: %v", err)
|
||||
}
|
||||
defer ta.Cleanup()
|
||||
|
||||
// create a task, but it won't match the filter
|
||||
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Test", task.StatusBacklog, task.TypeStory); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
|
||||
ta.NavController.PushView(model.MakePluginViewID("EmptyTest"), nil)
|
||||
ta.Draw()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// press 'A' — no task selected, preflight should fail, no prompt
|
||||
ta.SendKey(tcell.KeyRune, 'A', tcell.ModNone)
|
||||
if iv != nil && iv.IsInputBoxVisible() {
|
||||
t.Fatal("input prompt should not open when no task is selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputAction_DraftSearchSurvivesRefresh(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
iv := getActiveInputableView(ta)
|
||||
|
||||
// open search and type (but don't submit)
|
||||
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
|
||||
ta.SendText("draft")
|
||||
|
||||
if !iv.IsInputBoxFocused() {
|
||||
t.Fatal("search box should be focused")
|
||||
}
|
||||
|
||||
// simulate a store refresh (which triggers view rebuild)
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
ta.Draw()
|
||||
|
||||
// search box should still be visible after refresh
|
||||
if !iv.IsInputBoxVisible() {
|
||||
t.Fatal("draft search should survive store refresh/rebuild")
|
||||
}
|
||||
|
||||
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
}
|
||||
|
||||
func TestInputAction_AddTagMutation(t *testing.T) {
|
||||
ta := setupInputActionTest(t)
|
||||
defer ta.Cleanup()
|
||||
|
||||
ta.SendKey(tcell.KeyRune, 't', tcell.ModNone)
|
||||
ta.SendText("urgent")
|
||||
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
|
||||
|
||||
if err := ta.TaskStore.Reload(); err != nil {
|
||||
t.Fatalf("failed to reload: %v", err)
|
||||
}
|
||||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
found := false
|
||||
for _, tag := range updated.Tags {
|
||||
if tag == "urgent" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected 'urgent' tag, got %v", updated.Tags)
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,7 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
|
|||
updated := ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatalf("expected task TIKI-1 to exist")
|
||||
return
|
||||
}
|
||||
if updated.Status != task.StatusDone {
|
||||
t.Fatalf("expected status done, got %v", updated.Status)
|
||||
|
|
@ -87,6 +88,7 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
|
|||
updated = ta.TaskStore.GetTask("TIKI-1")
|
||||
if updated == nil {
|
||||
t.Fatalf("expected task TIKI-1 to exist")
|
||||
return
|
||||
}
|
||||
if updated.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected status backlog, got %v", updated.Status)
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ func TestPluginActions_DeleteTask_DKey(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask("DELETE-1")
|
||||
if task == nil {
|
||||
t.Fatal("Test task DELETE-1 not found before deletion")
|
||||
return
|
||||
}
|
||||
|
||||
// Press 'd' to delete (assumes first task is selected)
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ func TestRefresh_ExternalModification(t *testing.T) {
|
|||
taskAfter := ta.TaskStore.GetTask(taskID)
|
||||
if taskAfter == nil {
|
||||
t.Fatalf("task should still exist after refresh")
|
||||
return
|
||||
}
|
||||
if taskAfter.Title != "Modified Title" {
|
||||
t.Errorf("task title in store = %q, want %q", taskAfter.Title, "Modified Title")
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ func TestTaskDetailView_InlineTitleEdit_Save(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "New Edited Title" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "New Edited Title")
|
||||
|
|
@ -214,6 +215,7 @@ func TestTaskDetailView_InlineTitleEdit_Cancel(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != originalTitle {
|
||||
t.Errorf("title = %q, want %q (should not have changed)", task.Title, originalTitle)
|
||||
|
|
@ -471,6 +473,7 @@ func TestTaskDetailView_InlineEdit_PreservesOtherFields(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Title != "New Title" {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ func TestTaskEdit_ShiftTabBackward(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify points changed from default 1 to 2
|
||||
|
|
@ -102,6 +103,7 @@ func TestTaskEdit_StatusCycling(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Status != taskpkg.StatusDone {
|
||||
|
|
@ -151,6 +153,7 @@ func TestTaskEdit_TypeToggling(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Type != taskpkg.TypeEpic {
|
||||
|
|
@ -199,6 +202,7 @@ func TestTaskEdit_AssigneeInput(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Current behavior: appends to default "Unassigned" text
|
||||
|
|
@ -240,6 +244,7 @@ func TestTaskEdit_SaveAndContinue(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Title != "New Title" {
|
||||
|
|
@ -298,6 +303,7 @@ func TestTaskEdit_EscapeAndReEdit(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Title != "New Title" {
|
||||
|
|
@ -345,6 +351,7 @@ func TestTaskEdit_PriorityRange(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Priority != 5 {
|
||||
|
|
@ -394,6 +401,7 @@ func TestTaskEdit_PointsRange(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
|
||||
if task.Points != 7 {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ func TestNewTask_Enter_SavesAndCreatesFile(t *testing.T) {
|
|||
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "My New Task")
|
||||
if task == nil {
|
||||
t.Fatalf("new task not found in store")
|
||||
return
|
||||
}
|
||||
if task.Title != "My New Task" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "My New Task")
|
||||
|
|
@ -130,6 +131,7 @@ func TestNewTask_CtrlS_SavesAndCreatesFile(t *testing.T) {
|
|||
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "Task Saved With CtrlS")
|
||||
if task == nil {
|
||||
t.Fatalf("new task not found in store")
|
||||
return
|
||||
}
|
||||
if task.Title != "Task Saved With CtrlS" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "Task Saved With CtrlS")
|
||||
|
|
@ -277,6 +279,7 @@ func TestTaskEdit_EnterInPointsFieldDoesNotSave(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != originalTitle {
|
||||
t.Errorf("title was saved when it shouldn't have been: got %q, want %q", task.Title, originalTitle)
|
||||
|
|
@ -320,6 +323,7 @@ func TestTaskEdit_TitleChangesSaved(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "Updated Title" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "Updated Title")
|
||||
|
|
@ -371,6 +375,7 @@ func TestTaskEdit_CtrlS_FromPointsField_Saves(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "Modified Title" {
|
||||
t.Errorf("title = %q, want %q (Ctrl+S should save from any field)", task.Title, "Modified Title")
|
||||
|
|
@ -413,6 +418,7 @@ func TestTaskEdit_Escape_FromTitleField_Cancels(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != originalTitle {
|
||||
t.Errorf("title = %q, want %q (Escape should cancel)", task.Title, originalTitle)
|
||||
|
|
@ -498,6 +504,7 @@ func TestTaskEdit_Escape_FromPointsField_Cancels(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != originalTitle {
|
||||
t.Errorf("title = %q, want %q (Escape should cancel from any field)", task.Title, originalTitle)
|
||||
|
|
@ -560,6 +567,7 @@ func TestTaskEdit_Tab_NavigatesForward(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Points != 5 {
|
||||
t.Errorf("points = %d, want 5 (Tab should navigate to Points field)", task.Points)
|
||||
|
|
@ -611,6 +619,7 @@ func TestTaskEdit_Navigation_PreservesChanges(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "New Title" {
|
||||
t.Errorf("title = %q, want %q (changes should be preserved during navigation)", task.Title, "New Title")
|
||||
|
|
@ -675,6 +684,7 @@ func TestTaskEdit_MultipleFields_AllSaved(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "New Multi-Field Title" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "New Multi-Field Title")
|
||||
|
|
@ -703,6 +713,7 @@ func TestTaskEdit_MultipleFields_AllDiscarded(t *testing.T) {
|
|||
task := ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found after creation")
|
||||
return
|
||||
}
|
||||
task.Priority = 3
|
||||
task.Points = 5
|
||||
|
|
@ -745,6 +756,7 @@ func TestTaskEdit_MultipleFields_AllDiscarded(t *testing.T) {
|
|||
task = ta.TaskStore.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatalf("task not found")
|
||||
return
|
||||
}
|
||||
if task.Title != "Original Title" {
|
||||
t.Errorf("title = %q, want %q (all changes should be discarded)", task.Title, "Original Title")
|
||||
|
|
@ -795,6 +807,7 @@ func TestNewTask_MultipleFields_AllSaved(t *testing.T) {
|
|||
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "New Task With Multiple Fields")
|
||||
if task == nil {
|
||||
t.Fatalf("new task not found in store")
|
||||
return
|
||||
}
|
||||
if task.Title != "New Task With Multiple Fields" {
|
||||
t.Errorf("title = %q, want %q", task.Title, "New Task With Multiple Fields")
|
||||
|
|
@ -854,6 +867,7 @@ func TestNewTask_AfterEditingExistingTask_StatusAndTypeNotCorrupted(t *testing.T
|
|||
newTask := findTaskByTitle(ta.TaskStore.GetAllTasks(), "New Task After Edit")
|
||||
if newTask == nil {
|
||||
t.Fatalf("new task not found in store")
|
||||
return
|
||||
}
|
||||
if newTask.Title != "New Task After Edit" {
|
||||
t.Errorf("title = %q, want %q", newTask.Title, "New Task After Edit")
|
||||
|
|
@ -908,6 +922,7 @@ func TestNewTask_WithStatusAndType_Saves(t *testing.T) {
|
|||
task := findTaskByTitle(ta.TaskStore.GetAllTasks(), "Hey")
|
||||
if task == nil {
|
||||
t.Fatalf("new task not found in store")
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("Task found: Title=%q, Status=%v, Type=%v", task.Title, task.Status, task.Type)
|
||||
|
|
|
|||
|
|
@ -4,19 +4,22 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/boolean-maybe/tiki/view"
|
||||
)
|
||||
|
||||
// NewApp creates a tview application.
|
||||
func NewApp() *tview.Application {
|
||||
tview.Borders.HorizontalFocus = tview.Borders.Horizontal
|
||||
tview.Borders.VerticalFocus = tview.Borders.Vertical
|
||||
tview.Borders.TopLeftFocus = tview.Borders.TopLeft
|
||||
tview.Borders.TopRightFocus = tview.Borders.TopRight
|
||||
tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
|
||||
tview.Borders.BottomRightFocus = tview.Borders.BottomRight
|
||||
return tview.NewApplication()
|
||||
}
|
||||
|
||||
// Run runs the tview application.
|
||||
// Returns an error if the application fails to run.
|
||||
func Run(app *tview.Application, rootLayout *view.RootLayout) error {
|
||||
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
|
||||
// Run runs the tview application with the given root primitive (typically a tview.Pages).
|
||||
func Run(app *tview.Application, root tview.Primitive) error {
|
||||
app.SetRoot(root, true).EnableMouse(false)
|
||||
if err := app.Run(); err != nil {
|
||||
return fmt.Errorf("run application: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,22 +9,27 @@ import (
|
|||
)
|
||||
|
||||
// InstallGlobalInputCapture installs the global keyboard handler
|
||||
// (header toggle, statusline auto-hide dismiss, router dispatch).
|
||||
// (palette modal short-circuit, statusline auto-hide dismiss, router dispatch).
|
||||
// F10 (toggle header) and * (open palette) are both routed through InputRouter
|
||||
// rather than handled here, so keyboard and palette-entered globals behave identically.
|
||||
func InstallGlobalInputCapture(
|
||||
app *tview.Application,
|
||||
headerConfig *model.HeaderConfig,
|
||||
paletteConfig *model.ActionPaletteConfig,
|
||||
statuslineConfig *model.StatuslineConfig,
|
||||
inputRouter *controller.InputRouter,
|
||||
navController *controller.NavigationController,
|
||||
) {
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
// while the palette is visible, pass the event through unchanged so the
|
||||
// focused palette input field receives it. Do not dismiss statusline or
|
||||
// dispatch through InputRouter — the palette is modal.
|
||||
if paletteConfig != nil && paletteConfig.IsVisible() {
|
||||
return event
|
||||
}
|
||||
|
||||
// dismiss auto-hide statusline messages on any keypress
|
||||
statuslineConfig.DismissAutoHide()
|
||||
|
||||
if event.Key() == tcell.KeyF10 {
|
||||
headerConfig.ToggleUserPreference()
|
||||
return nil
|
||||
}
|
||||
if inputRouter.HandleInput(event, navController.CurrentView()) {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
|
|
@ -20,6 +21,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/util/sysinfo"
|
||||
"github.com/boolean-maybe/tiki/view"
|
||||
"github.com/boolean-maybe/tiki/view/header"
|
||||
"github.com/boolean-maybe/tiki/view/palette"
|
||||
"github.com/boolean-maybe/tiki/view/statusline"
|
||||
)
|
||||
|
||||
|
|
@ -47,6 +49,10 @@ type Result struct {
|
|||
StatuslineConfig *model.StatuslineConfig
|
||||
StatuslineWidget *statusline.StatuslineWidget
|
||||
RootLayout *view.RootLayout
|
||||
PaletteConfig *model.ActionPaletteConfig
|
||||
ActionPalette *palette.ActionPalette
|
||||
ViewContext *model.ViewContext
|
||||
AppRoot tview.Primitive // Pages root for app.SetRoot
|
||||
Context context.Context
|
||||
CancelFunc context.CancelFunc
|
||||
TikiSkillContent string
|
||||
|
|
@ -77,9 +83,9 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
slog.Warn("failed to install default workflow", "error", err)
|
||||
}
|
||||
|
||||
// Phase 2.7: Load status definitions from workflow.yaml
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
return nil, fmt.Errorf("load status registry: %w", err)
|
||||
// Phase 2.7: Load workflow registries (statuses, types, custom fields)
|
||||
if err := config.LoadWorkflowRegistries(); err != nil {
|
||||
return nil, fmt.Errorf("load workflow registries: %w", err)
|
||||
}
|
||||
|
||||
// Phase 3: Configuration and logging
|
||||
|
|
@ -116,7 +122,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
return nil, err
|
||||
}
|
||||
InitPluginActionRegistry(plugins)
|
||||
syncHeaderPluginActions(headerConfig)
|
||||
viewContext := model.NewViewContext()
|
||||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
|
|
@ -163,11 +169,12 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
|
||||
})
|
||||
|
||||
headerWidget := header.NewHeaderWidget(headerConfig)
|
||||
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
|
||||
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
|
||||
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: headerConfig,
|
||||
ViewContext: viewContext,
|
||||
LayoutModel: layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: taskStore,
|
||||
|
|
@ -184,9 +191,38 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application)
|
||||
triggerEngine.StartScheduler(ctx)
|
||||
|
||||
// Phase 11.5: Action palette
|
||||
paletteConfig := model.NewActionPaletteConfig()
|
||||
inputRouter.SetHeaderConfig(headerConfig)
|
||||
inputRouter.SetPaletteConfig(paletteConfig)
|
||||
|
||||
actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, controllers.Nav)
|
||||
actionPalette.SetChangedFunc()
|
||||
|
||||
// Build Pages root: base = rootLayout, overlay = palette
|
||||
pages := tview.NewPages()
|
||||
pages.AddPage("base", rootLayout.GetPrimitive(), true, true)
|
||||
paletteOverlay := buildPaletteOverlay(actionPalette)
|
||||
pages.AddPage("palette", paletteOverlay, true, false)
|
||||
|
||||
// Wire palette visibility to Pages show/hide and focus management
|
||||
var previousFocus tview.Primitive
|
||||
paletteConfig.AddListener(func() {
|
||||
if paletteConfig.IsVisible() {
|
||||
previousFocus = application.GetFocus()
|
||||
actionPalette.OnShow()
|
||||
pages.ShowPage("palette")
|
||||
application.SetFocus(actionPalette.GetFilterInput())
|
||||
} else {
|
||||
pages.HidePage("palette")
|
||||
restoreFocusAfterPalette(application, previousFocus, rootLayout)
|
||||
previousFocus = nil
|
||||
}
|
||||
})
|
||||
|
||||
// Phase 12: Navigation and input wiring
|
||||
wireNavigation(controllers.Nav, layoutModel, rootLayout)
|
||||
app.InstallGlobalInputCapture(application, headerConfig, statuslineConfig, inputRouter, controllers.Nav)
|
||||
app.InstallGlobalInputCapture(application, paletteConfig, statuslineConfig, inputRouter, controllers.Nav)
|
||||
|
||||
// Phase 13: Initial view — use the first plugin marked default: true,
|
||||
// or fall back to the first plugin in the list.
|
||||
|
|
@ -212,6 +248,10 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
StatuslineConfig: statuslineConfig,
|
||||
StatuslineWidget: statuslineWidget,
|
||||
RootLayout: rootLayout,
|
||||
PaletteConfig: paletteConfig,
|
||||
ActionPalette: actionPalette,
|
||||
ViewContext: viewContext,
|
||||
AppRoot: pages,
|
||||
Context: ctx,
|
||||
CancelFunc: cancel,
|
||||
TikiSkillContent: tikiSkillContent,
|
||||
|
|
@ -219,12 +259,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// syncHeaderPluginActions syncs plugin action shortcuts from the controller registry
|
||||
// into the header model.
|
||||
func syncHeaderPluginActions(headerConfig *model.HeaderConfig) {
|
||||
headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
|
||||
}
|
||||
|
||||
// wireOnViewActivated wires focus setters into views as they become active.
|
||||
func wireOnViewActivated(rootLayout *view.RootLayout, app *tview.Application) {
|
||||
rootLayout.SetOnViewActivated(func(v controller.View) {
|
||||
|
|
@ -246,6 +280,59 @@ func wireNavigation(navController *controller.NavigationController, layoutModel
|
|||
navController.SetActiveViewGetter(rootLayout.GetContentView)
|
||||
}
|
||||
|
||||
// paletteOverlayFlex is a Flex that recomputes the palette width on every draw
|
||||
// to maintain 1/3 terminal width with a minimum floor.
|
||||
type paletteOverlayFlex struct {
|
||||
*tview.Flex
|
||||
palette tview.Primitive
|
||||
spacer *tview.Flex
|
||||
lastPaletteSize int
|
||||
}
|
||||
|
||||
func buildPaletteOverlay(ap *palette.ActionPalette) *paletteOverlayFlex {
|
||||
overlay := &paletteOverlayFlex{
|
||||
Flex: tview.NewFlex(),
|
||||
palette: ap.GetPrimitive(),
|
||||
}
|
||||
overlay.spacer = tview.NewFlex()
|
||||
overlay.Flex.AddItem(overlay.spacer, 0, 1, false)
|
||||
overlay.Flex.AddItem(overlay.palette, palette.PaletteMinWidth, 0, true)
|
||||
overlay.lastPaletteSize = palette.PaletteMinWidth
|
||||
return overlay
|
||||
}
|
||||
|
||||
func (o *paletteOverlayFlex) Draw(screen tcell.Screen) {
|
||||
_, _, w, _ := o.GetRect()
|
||||
pw := w / 3
|
||||
if pw < palette.PaletteMinWidth {
|
||||
pw = palette.PaletteMinWidth
|
||||
}
|
||||
if pw != o.lastPaletteSize {
|
||||
o.Flex.Clear()
|
||||
o.Flex.AddItem(o.spacer, 0, 1, false)
|
||||
o.Flex.AddItem(o.palette, pw, 0, true)
|
||||
o.lastPaletteSize = pw
|
||||
}
|
||||
o.Flex.Draw(screen)
|
||||
}
|
||||
|
||||
// restoreFocusAfterPalette restores focus to the previously focused primitive,
|
||||
// falling back to FocusRestorer on the active view, then to the content view root.
|
||||
func restoreFocusAfterPalette(application *tview.Application, previousFocus tview.Primitive, rootLayout *view.RootLayout) {
|
||||
if previousFocus != nil {
|
||||
application.SetFocus(previousFocus)
|
||||
return
|
||||
}
|
||||
if contentView := rootLayout.GetContentView(); contentView != nil {
|
||||
if restorer, ok := contentView.(controller.FocusRestorer); ok {
|
||||
if restorer.RestoreFocus() {
|
||||
return
|
||||
}
|
||||
}
|
||||
application.SetFocus(contentView.GetPrimitive())
|
||||
}
|
||||
}
|
||||
|
||||
// InitColorAndGradientSupport collects system information, auto-corrects TERM if needed,
|
||||
// and initializes gradient support flags based on terminal color capabilities.
|
||||
// Returns the collected SystemInfo for use in bootstrap result.
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
|
|||
return "", fmt.Errorf("project not initialized: run 'tiki init' first")
|
||||
}
|
||||
|
||||
// Load status definitions before creating tasks
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
return "", fmt.Errorf("load status registry: %w", err)
|
||||
// load workflow registries (statuses, types, custom fields) before creating tasks
|
||||
if err := config.LoadWorkflowRegistries(); err != nil {
|
||||
return "", fmt.Errorf("load workflow registries: %w", err)
|
||||
}
|
||||
|
||||
gate := service.BuildGate()
|
||||
|
|
|
|||
|
|
@ -134,6 +134,17 @@ func extractFieldValue(t *task.Task, name string) interface{} {
|
|||
case "updatedAt":
|
||||
return t.UpdatedAt
|
||||
default:
|
||||
fd, ok := workflow.Field(name)
|
||||
if !ok || !fd.Custom {
|
||||
return nil
|
||||
}
|
||||
if t.CustomFields != nil {
|
||||
if v, exists := t.CustomFields[name]; exists {
|
||||
return v
|
||||
}
|
||||
}
|
||||
// unset custom field — return nil to match executor semantics;
|
||||
// renderValue converts nil to "" so unset fields display as blank
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +164,10 @@ func renderValue(val interface{}, vt workflow.ValueType) string {
|
|||
return renderList(val)
|
||||
case workflow.TypeInt:
|
||||
return renderInt(val)
|
||||
case workflow.TypeEnum:
|
||||
return escapeScalar(fmt.Sprint(val))
|
||||
case workflow.TypeBool:
|
||||
return fmt.Sprint(val)
|
||||
default:
|
||||
return escapeScalar(fmt.Sprint(val))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func TestTableFormatterProjectedFields(t *testing.T) {
|
||||
|
|
@ -556,3 +557,139 @@ func TestEscapeScalar(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCustomFields(t *testing.T) {
|
||||
// register custom fields so extractFieldValue and resolveFields find them
|
||||
initTestRegistries()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
|
||||
{Name: "score", Type: workflow.TypeInt},
|
||||
{Name: "active", Type: workflow.TypeBool},
|
||||
{Name: "notes", Type: workflow.TypeString},
|
||||
}); err != nil {
|
||||
t.Fatalf("register custom fields: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"severity", "score", "active", "notes"},
|
||||
Tasks: []*task.Task{
|
||||
{
|
||||
ID: "TIKI-CF0001", Title: "Custom", Status: "ready",
|
||||
CustomFields: map[string]interface{}{
|
||||
"severity": "high",
|
||||
"score": 42,
|
||||
"active": true,
|
||||
"notes": "important",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "high") {
|
||||
t.Errorf("missing severity value:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "42") {
|
||||
t.Errorf("missing score value:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "true") {
|
||||
t.Errorf("missing active value:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "important") {
|
||||
t.Errorf("missing notes value:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMissingCustomFields(t *testing.T) {
|
||||
initTestRegistries()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "severity", Type: workflow.TypeEnum, AllowedValues: []string{"low", "medium", "high"}},
|
||||
{Name: "score", Type: workflow.TypeInt},
|
||||
{Name: "active", Type: workflow.TypeBool},
|
||||
{Name: "notes", Type: workflow.TypeString},
|
||||
}); err != nil {
|
||||
t.Fatalf("register custom fields: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
// task with no custom fields set — should render as empty (nil → "")
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"score", "active"},
|
||||
Tasks: []*task.Task{
|
||||
{ID: "TIKI-CF0002", Title: "Empty", Status: "ready"},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
lines := strings.Split(out, "\n")
|
||||
// data row is lines[3]
|
||||
dataRow := lines[3]
|
||||
parts := strings.Split(dataRow, "|")
|
||||
|
||||
// score (int, unset) → ""
|
||||
scoreCell := strings.TrimSpace(parts[1])
|
||||
if scoreCell != "" {
|
||||
t.Errorf("unset int custom field should render as empty, got %q", scoreCell)
|
||||
}
|
||||
|
||||
// active (bool, unset) → ""
|
||||
activeCell := strings.TrimSpace(parts[2])
|
||||
if activeCell != "" {
|
||||
t.Errorf("unset bool custom field should render as empty, got %q", activeCell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSetToZeroVsUnset(t *testing.T) {
|
||||
initTestRegistries()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "score", Type: workflow.TypeInt},
|
||||
{Name: "active", Type: workflow.TypeBool},
|
||||
}); err != nil {
|
||||
t.Fatalf("register custom fields: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"score", "active"},
|
||||
Tasks: []*task.Task{
|
||||
{ID: "TIKI-Z00001", Title: "Explicit zero", Status: "ready",
|
||||
CustomFields: map[string]interface{}{"score": 0, "active": false}},
|
||||
{ID: "TIKI-Z00002", Title: "Unset", Status: "ready"},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
lines := strings.Split(out, "\n")
|
||||
|
||||
// first data row (explicit zero): score=0, active=false
|
||||
row1 := strings.Split(lines[3], "|")
|
||||
if s := strings.TrimSpace(row1[1]); s != "0" {
|
||||
t.Errorf("explicit zero int should render as '0', got %q", s)
|
||||
}
|
||||
if s := strings.TrimSpace(row1[2]); s != "false" {
|
||||
t.Errorf("explicit false bool should render as 'false', got %q", s)
|
||||
}
|
||||
|
||||
// second data row (unset): both empty
|
||||
row2 := strings.Split(lines[4], "|")
|
||||
if s := strings.TrimSpace(row2[1]); s != "" {
|
||||
t.Errorf("unset int should render as empty, got %q", s)
|
||||
}
|
||||
if s := strings.TrimSpace(row2[2]); s != "" {
|
||||
t.Errorf("unset bool should render as empty, got %q", s)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ func TestRunQueryUpdatePersists(t *testing.T) {
|
|||
updated := s.GetTask("TIKI-AAA001")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found after update")
|
||||
return
|
||||
}
|
||||
if updated.Title != "Updated API" {
|
||||
t.Errorf("expected title 'Updated API', got %q", updated.Title)
|
||||
|
|
@ -386,6 +387,7 @@ func TestRunQueryCreatePersists(t *testing.T) {
|
|||
}
|
||||
if found == nil {
|
||||
t.Fatal("created task not found in store")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(found.ID, "TIKI-") || len(found.ID) != 11 {
|
||||
t.Errorf("ID = %q, want TIKI-XXXXXX format (11 chars)", found.ID)
|
||||
|
|
@ -431,14 +433,15 @@ func TestRunQueryCreateTemplateDefaults(t *testing.T) {
|
|||
}
|
||||
if found == nil {
|
||||
t.Fatal("created task not found in store")
|
||||
return
|
||||
}
|
||||
// InMemoryStore template has tags=["idea"], so result should be ["idea", "extra"]
|
||||
if len(found.Tags) != 2 || found.Tags[0] != "idea" || found.Tags[1] != "extra" {
|
||||
t.Errorf("tags = %v, want [idea extra]", found.Tags)
|
||||
}
|
||||
// priority should be template default (7)
|
||||
if found.Priority != 7 {
|
||||
t.Errorf("priority = %d, want 7 (template default)", found.Priority)
|
||||
// priority should be template default (3 = medium)
|
||||
if found.Priority != 3 {
|
||||
t.Errorf("priority = %d, want 3 (template default)", found.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,32 +6,54 @@ import (
|
|||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// workflowSchema adapts workflow.Fields(), config.GetStatusRegistry(), and
|
||||
// config.GetTypeRegistry() into the ruki.Schema interface used by the parser
|
||||
// and executor.
|
||||
// workflowSchema adapts a snapshot of workflow.Fields(), config.GetStatusRegistry(),
|
||||
// and config.GetTypeRegistry() into the ruki.Schema interface used by the parser
|
||||
// and executor. The field catalog is snapshotted at construction time so an old
|
||||
// schema never observes newly loaded custom fields through live global lookups.
|
||||
type workflowSchema struct {
|
||||
statusReg *workflow.StatusRegistry
|
||||
typeReg *workflow.TypeRegistry
|
||||
statusReg *workflow.StatusRegistry
|
||||
typeReg *workflow.TypeRegistry
|
||||
fieldsByName map[string]ruki.FieldSpec // snapshotted at construction
|
||||
}
|
||||
|
||||
// NewSchema constructs a ruki.Schema backed by the loaded workflow registries.
|
||||
// Must be called after config.LoadStatusRegistry().
|
||||
// Snapshots the current field catalog (built-in + custom) so the schema is
|
||||
// immutable after creation. Must be called after config.LoadStatusRegistry()
|
||||
// (and config.LoadCustomFields() if custom fields are in use).
|
||||
func NewSchema() ruki.Schema {
|
||||
fields := workflow.Fields() // includes custom fields
|
||||
byName := make(map[string]ruki.FieldSpec, len(fields))
|
||||
for _, fd := range fields {
|
||||
spec := ruki.FieldSpec{
|
||||
Name: fd.Name,
|
||||
Type: mapValueType(fd.Type),
|
||||
Custom: fd.Custom,
|
||||
}
|
||||
if fd.AllowedValues != nil {
|
||||
spec.AllowedValues = make([]string, len(fd.AllowedValues))
|
||||
copy(spec.AllowedValues, fd.AllowedValues)
|
||||
}
|
||||
byName[fd.Name] = spec
|
||||
}
|
||||
return &workflowSchema{
|
||||
statusReg: config.GetStatusRegistry(),
|
||||
typeReg: config.GetTypeRegistry(),
|
||||
statusReg: config.GetStatusRegistry(),
|
||||
typeReg: config.GetTypeRegistry(),
|
||||
fieldsByName: byName,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *workflowSchema) Field(name string) (ruki.FieldSpec, bool) {
|
||||
fd, ok := workflow.Field(name)
|
||||
spec, ok := s.fieldsByName[name]
|
||||
if !ok {
|
||||
return ruki.FieldSpec{}, false
|
||||
}
|
||||
return ruki.FieldSpec{
|
||||
Name: fd.Name,
|
||||
Type: mapValueType(fd.Type),
|
||||
}, true
|
||||
// return a defensive copy so callers cannot mutate schema state
|
||||
out := spec
|
||||
if spec.AllowedValues != nil {
|
||||
out.AllowedValues = make([]string, len(spec.AllowedValues))
|
||||
copy(out.AllowedValues, spec.AllowedValues)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (s *workflowSchema) NormalizeStatus(raw string) (string, bool) {
|
||||
|
|
@ -77,6 +99,8 @@ func mapValueType(wt workflow.ValueType) ruki.ValueType {
|
|||
return ruki.ValueStatus
|
||||
case workflow.TypeTaskType:
|
||||
return ruki.ValueTaskType
|
||||
case workflow.TypeEnum:
|
||||
return ruki.ValueEnum
|
||||
default:
|
||||
return ruki.ValueString
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func TestSchemaFieldMapping(t *testing.T) {
|
|||
spec, ok := s.Field(tt.name)
|
||||
if !ok {
|
||||
t.Fatalf("Field(%q) not found", tt.name)
|
||||
return
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Errorf("Field(%q).Type = %d, want %d", tt.name, spec.Type, tt.wantType)
|
||||
|
|
@ -103,9 +104,9 @@ func TestSchemaNormalizeType(t *testing.T) {
|
|||
}{
|
||||
{"story", "story", true},
|
||||
{"bug", "bug", true},
|
||||
{"feature", "story", true}, // alias
|
||||
{"task", "story", true}, // alias
|
||||
{"unknown_type", "story", false}, // falls back to first type
|
||||
{"feature", "", false}, // no aliases
|
||||
{"task", "", false}, // no aliases
|
||||
{"unknown_type", "", false}, // unknown returns empty
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ func TestParseViewerInputGitHubBlobURL(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatal("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputGitHub {
|
||||
t.Fatalf("expected github input, got %s", spec.Kind)
|
||||
|
|
@ -96,6 +97,7 @@ func TestParseViewerInputGitHubSingleSegmentFallsThrough(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatal("expected viewer mode (treated as file path)")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputFile {
|
||||
t.Fatalf("single-segment github path should fall through to file, got %s", spec.Kind)
|
||||
|
|
@ -114,6 +116,7 @@ func TestParseViewerInputGitLabBasic(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatal("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputGitLab {
|
||||
t.Fatalf("expected gitlab input, got %s", spec.Kind)
|
||||
|
|
@ -167,6 +170,7 @@ func TestParseViewerInputGitLabHTTPS(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatal("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputGitLab {
|
||||
t.Fatalf("expected gitlab input, got %s", spec.Kind)
|
||||
|
|
@ -183,6 +187,7 @@ func TestParseViewerInputGitLabSingleSegmentFallsThrough(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatal("expected viewer mode (file path fallthrough)")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputFile {
|
||||
t.Fatalf("single-segment gitlab path should fall through to file, got %s", spec.Kind)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ func TestParseViewerInputFile(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputFile {
|
||||
t.Fatalf("expected file input, got %s", spec.Kind)
|
||||
|
|
@ -36,6 +37,7 @@ func TestParseViewerInputStdin(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputStdin {
|
||||
t.Fatalf("expected stdin input, got %s", spec.Kind)
|
||||
|
|
@ -49,6 +51,7 @@ func TestParseViewerInputURL(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputURL {
|
||||
t.Fatalf("expected url input, got %s", spec.Kind)
|
||||
|
|
@ -65,6 +68,7 @@ func TestParseViewerInputGitHub(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputGitHub {
|
||||
t.Fatalf("expected github input, got %s", spec.Kind)
|
||||
|
|
@ -100,6 +104,7 @@ func TestParseViewerInputFlags(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputFile {
|
||||
t.Fatalf("expected file input, got %s", spec.Kind)
|
||||
|
|
@ -113,6 +118,7 @@ func TestParseViewerInputLogLevelMissingValue(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputFile {
|
||||
t.Fatalf("expected file input, got %s", spec.Kind)
|
||||
|
|
@ -165,6 +171,7 @@ func TestParseViewerInputImageFile(t *testing.T) {
|
|||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected viewer mode")
|
||||
return
|
||||
}
|
||||
if spec.Kind != InputImage {
|
||||
t.Fatalf("expected image input, got %s", spec.Kind)
|
||||
|
|
|
|||
43
llms.txt
Normal file
43
llms.txt
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# tiki
|
||||
|
||||
> Terminal-first Markdown workspace for tasks, docs, and notes — stored in git, queried with ruki, extended with plugins and AI skills
|
||||
|
||||
tiki manages two kinds of content: **tikis** (tasks/tickets as Markdown+YAML in `.doc/tiki/`) and **dokis** (documentation as Markdown in `.doc/doki/`). It includes a standalone Markdown viewer with images, Mermaid diagrams, and link navigation. Tasks support priority, status, assignee, size, tags, and dependencies. Views are Kanban/Scrum boards with burndown charts.
|
||||
|
||||
Everything beyond the core is plugin-driven. `workflow.yaml` defines statuses and plugin views. Each plugin uses **ruki** — an SQL-like language for selecting, filtering, sorting, and updating tasks. Triggers (ruki statements that fire on task changes) enforce workflow rules. AI skills let Claude Code, Gemini CLI, Codex, and Opencode manage tikis and dokis through natural language.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Quick Start](.doc/doki/doc/quick-start.md): getting started — markdown viewer, file management, tiki board, keyboard shortcuts
|
||||
- [Tiki Format](.doc/doki/doc/tiki-format.md): .doc directory structure, tiki Markdown+YAML spec, field types
|
||||
- [Configuration](.doc/doki/doc/config.md): config.yaml, workflow.yaml, config directories, settings reference
|
||||
- [Custom Statuses and Types](.doc/doki/doc/custom-status-type.md): type and status definition rules, validation, key normalization, inheritance
|
||||
- [Customization](.doc/doki/doc/customization.md): workflow statuses, plugin definitions, lanes, actions, views
|
||||
- [Installation](.doc/doki/doc/install.md): macOS, Linux, Windows, manual install
|
||||
- [Markdown Viewer](.doc/doki/doc/markdown-viewer.md): pager commands, link navigation, image/diagram rendering
|
||||
- [Quick Capture](.doc/doki/doc/quick-capture.md): creating tikis from CLI without opening the TUI
|
||||
|
||||
## Ruki Language
|
||||
|
||||
- [Quick Start](.doc/doki/doc/ruki/quick-start.md): mental model, CRUD operations, conditions, pipes, triggers
|
||||
- [Examples](.doc/doki/doc/ruki/examples.md): practical select/update/trigger statements with explanations
|
||||
- [Syntax](.doc/doki/doc/ruki/syntax.md): lexical structure, grammar, EBNF, operator tokens
|
||||
- [Semantics](.doc/doki/doc/ruki/semantics.md): statement behavior, trigger execution, expression evaluation
|
||||
- [Triggers](.doc/doki/doc/ruki/triggers.md): trigger configuration, patterns, execution pipeline, error handling
|
||||
- [Types and Values](.doc/doki/doc/ruki/types-and-values.md): value types, field catalog, type coercion
|
||||
- [Operators and Built-ins](.doc/doki/doc/ruki/operators-and-builtins.md): operator precedence, built-in functions
|
||||
|
||||
## AI Skills
|
||||
|
||||
- [AI Collaboration](.doc/doki/doc/ai.md): overview of AI integration, supported tools, prompt conventions
|
||||
- [Tiki Skill](ai/skills/tiki/SKILL.md): AI skill for creating, querying, updating, and deleting tikis
|
||||
- [Doki Skill](ai/skills/doki/SKILL.md): AI skill for managing documentation files
|
||||
|
||||
## Optional
|
||||
|
||||
- [Customization Examples](.doc/doki/doc/ideas/plugins.md): plugin recipes — assign, tags, Claude integration, scoped views
|
||||
- [Trigger Ideas](.doc/doki/doc/ideas/triggers.md): workflow trigger patterns — WIP limits, auto-assignment, staleness
|
||||
- [Themes](.doc/doki/doc/themes.md): theme configuration and available palettes
|
||||
- [Image Requirements](.doc/doki/doc/image-requirements.md): terminal image protocol support, SVG rendering dependencies
|
||||
- [Validation and Errors](.doc/doki/doc/ruki/validation-and-errors.md): ruki validation layers, error types, diagnostics
|
||||
- [Contributing](CONTRIBUTING.md): development setup, PR workflow, coding conventions
|
||||
22
main.go
22
main.go
|
|
@ -62,6 +62,11 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle workflow command
|
||||
if len(os.Args) > 1 && os.Args[1] == "workflow" {
|
||||
os.Exit(runWorkflow(os.Args[2:]))
|
||||
}
|
||||
|
||||
// Handle exec command: execute ruki statement and exit
|
||||
if len(os.Args) > 1 && os.Args[1] == "exec" {
|
||||
os.Exit(runExec(os.Args[2:]))
|
||||
|
|
@ -83,7 +88,7 @@ func main() {
|
|||
|
||||
// Handle viewer mode (standalone markdown viewer)
|
||||
// "init" is reserved to prevent treating it as a markdown file
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}})
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "workflow": {}})
|
||||
if err != nil {
|
||||
if errors.Is(err, viewer.ErrMultipleInputs) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
|
|
@ -121,10 +126,11 @@ func main() {
|
|||
defer result.App.Stop()
|
||||
defer result.HeaderWidget.Cleanup()
|
||||
defer result.RootLayout.Cleanup()
|
||||
defer result.ActionPalette.Cleanup()
|
||||
defer result.CancelFunc()
|
||||
|
||||
// Run application
|
||||
if err := app.Run(result.App, result.RootLayout); err != nil {
|
||||
if err := app.Run(result.App, result.AppRoot); err != nil {
|
||||
slog.Error("application error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -178,7 +184,11 @@ func runDemo() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// exit codes for tiki exec
|
||||
// errHelpRequested is returned by arg parsers when the user asks for help.
|
||||
// Callers should print usage and exit cleanly — not treat it as a real error.
|
||||
var errHelpRequested = errors.New("help requested")
|
||||
|
||||
// exit codes for CLI subcommands
|
||||
const (
|
||||
exitOK = 0
|
||||
exitInternal = 1
|
||||
|
|
@ -216,8 +226,8 @@ func runExec(args []string) int {
|
|||
_, _ = fmt.Fprintf(os.Stderr, "warning: install default workflow: %v\n", err)
|
||||
}
|
||||
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: load status registry: %v\n", err)
|
||||
if err := config.LoadWorkflowRegistries(); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: load workflow registries: %v\n", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +264,8 @@ Usage:
|
|||
tiki Launch TUI in initialized repo
|
||||
tiki init Initialize project in current git repo
|
||||
tiki exec '<statement>' Execute a ruki query and exit
|
||||
tiki workflow reset [target] Reset config files (--global, --local, --current)
|
||||
tiki workflow install <name> Install a workflow (--global, --local, --current)
|
||||
tiki demo Clone demo project and launch TUI
|
||||
tiki file.md/URL View markdown file or image
|
||||
echo "Title" | tiki Create task from piped input
|
||||
|
|
|
|||
80
model/action_palette_config.go
Normal file
80
model/action_palette_config.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package model
|
||||
|
||||
import "sync"
|
||||
|
||||
// ActionPaletteConfig manages the visibility state of the action palette overlay.
|
||||
// The palette reads view metadata from ViewContext and action rows from live
|
||||
// controller registries — this config only tracks open/close state.
|
||||
type ActionPaletteConfig struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
visible bool
|
||||
|
||||
listeners map[int]func()
|
||||
nextListener int
|
||||
}
|
||||
|
||||
// NewActionPaletteConfig creates a new palette config (hidden by default).
|
||||
func NewActionPaletteConfig() *ActionPaletteConfig {
|
||||
return &ActionPaletteConfig{
|
||||
listeners: make(map[int]func()),
|
||||
nextListener: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// IsVisible returns whether the palette is currently visible.
|
||||
func (pc *ActionPaletteConfig) IsVisible() bool {
|
||||
pc.mu.RLock()
|
||||
defer pc.mu.RUnlock()
|
||||
return pc.visible
|
||||
}
|
||||
|
||||
// SetVisible sets the palette visibility and notifies listeners on change.
|
||||
func (pc *ActionPaletteConfig) SetVisible(visible bool) {
|
||||
pc.mu.Lock()
|
||||
changed := pc.visible != visible
|
||||
pc.visible = visible
|
||||
pc.mu.Unlock()
|
||||
if changed {
|
||||
pc.notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleVisible toggles the palette visibility.
|
||||
func (pc *ActionPaletteConfig) ToggleVisible() {
|
||||
pc.mu.Lock()
|
||||
pc.visible = !pc.visible
|
||||
pc.mu.Unlock()
|
||||
pc.notifyListeners()
|
||||
}
|
||||
|
||||
// AddListener registers a callback for palette config changes.
|
||||
// Returns a listener ID for removal.
|
||||
func (pc *ActionPaletteConfig) AddListener(listener func()) int {
|
||||
pc.mu.Lock()
|
||||
defer pc.mu.Unlock()
|
||||
id := pc.nextListener
|
||||
pc.nextListener++
|
||||
pc.listeners[id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
// RemoveListener removes a previously registered listener by ID.
|
||||
func (pc *ActionPaletteConfig) RemoveListener(id int) {
|
||||
pc.mu.Lock()
|
||||
defer pc.mu.Unlock()
|
||||
delete(pc.listeners, id)
|
||||
}
|
||||
|
||||
func (pc *ActionPaletteConfig) notifyListeners() {
|
||||
pc.mu.RLock()
|
||||
listeners := make([]func(), 0, len(pc.listeners))
|
||||
for _, listener := range pc.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
pc.mu.RUnlock()
|
||||
|
||||
for _, listener := range listeners {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
87
model/action_palette_config_test.go
Normal file
87
model/action_palette_config_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActionPaletteConfig_DefaultHidden(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
if pc.IsVisible() {
|
||||
t.Error("palette should be hidden by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_SetVisible(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
pc.SetVisible(true)
|
||||
if !pc.IsVisible() {
|
||||
t.Error("palette should be visible after SetVisible(true)")
|
||||
}
|
||||
pc.SetVisible(false)
|
||||
if pc.IsVisible() {
|
||||
t.Error("palette should be hidden after SetVisible(false)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_ToggleVisible(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
pc.ToggleVisible()
|
||||
if !pc.IsVisible() {
|
||||
t.Error("palette should be visible after first toggle")
|
||||
}
|
||||
pc.ToggleVisible()
|
||||
if pc.IsVisible() {
|
||||
t.Error("palette should be hidden after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_ListenerNotifiedOnChange(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
called := 0
|
||||
pc.AddListener(func() { called++ })
|
||||
|
||||
pc.SetVisible(true)
|
||||
if called != 1 {
|
||||
t.Errorf("listener should be called once on change, got %d", called)
|
||||
}
|
||||
|
||||
// no-op (already visible)
|
||||
pc.SetVisible(true)
|
||||
if called != 1 {
|
||||
t.Errorf("listener should not be called on no-op SetVisible, got %d", called)
|
||||
}
|
||||
|
||||
pc.SetVisible(false)
|
||||
if called != 2 {
|
||||
t.Errorf("listener should be called on hide, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_ToggleAlwaysNotifies(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
called := 0
|
||||
pc.AddListener(func() { called++ })
|
||||
|
||||
pc.ToggleVisible()
|
||||
pc.ToggleVisible()
|
||||
if called != 2 {
|
||||
t.Errorf("expected 2 notifications from toggle, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionPaletteConfig_RemoveListener(t *testing.T) {
|
||||
pc := NewActionPaletteConfig()
|
||||
called := 0
|
||||
id := pc.AddListener(func() { called++ })
|
||||
|
||||
pc.SetVisible(true)
|
||||
if called != 1 {
|
||||
t.Errorf("expected 1 call, got %d", called)
|
||||
}
|
||||
|
||||
pc.RemoveListener(id)
|
||||
pc.SetVisible(false)
|
||||
if called != 1 {
|
||||
t.Errorf("expected no more calls after removal, got %d", called)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,17 +25,13 @@ type StatValue struct {
|
|||
Priority int
|
||||
}
|
||||
|
||||
// HeaderConfig manages ALL header state - both content AND visibility.
|
||||
// HeaderConfig manages header visibility and burndown state.
|
||||
// View identity and actions are now in ViewContext.
|
||||
// Thread-safe model that notifies listeners when state changes.
|
||||
type HeaderConfig struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// Content state
|
||||
viewActions []HeaderAction
|
||||
pluginActions []HeaderAction
|
||||
viewName string // current view name for info section
|
||||
viewDescription string // current view description for info section
|
||||
burndown []store.BurndownPoint
|
||||
burndown []store.BurndownPoint
|
||||
|
||||
// Visibility state
|
||||
visible bool // current header visibility (may be overridden by fullscreen view)
|
||||
|
|
@ -56,59 +52,6 @@ func NewHeaderConfig() *HeaderConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// SetViewActions updates the view-specific header actions
|
||||
func (hc *HeaderConfig) SetViewActions(actions []HeaderAction) {
|
||||
hc.mu.Lock()
|
||||
hc.viewActions = actions
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetViewActions returns the current view's header actions
|
||||
func (hc *HeaderConfig) GetViewActions() []HeaderAction {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewActions
|
||||
}
|
||||
|
||||
// SetPluginActions updates the plugin navigation header actions
|
||||
func (hc *HeaderConfig) SetPluginActions(actions []HeaderAction) {
|
||||
hc.mu.Lock()
|
||||
hc.pluginActions = actions
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetPluginActions returns the plugin navigation header actions
|
||||
func (hc *HeaderConfig) GetPluginActions() []HeaderAction {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.pluginActions
|
||||
}
|
||||
|
||||
// SetViewInfo sets the current view name and description for the header info section
|
||||
func (hc *HeaderConfig) SetViewInfo(name, description string) {
|
||||
hc.mu.Lock()
|
||||
hc.viewName = name
|
||||
hc.viewDescription = description
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetViewName returns the current view name
|
||||
func (hc *HeaderConfig) GetViewName() string {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewName
|
||||
}
|
||||
|
||||
// GetViewDescription returns the current view description
|
||||
func (hc *HeaderConfig) GetViewDescription() string {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewDescription
|
||||
}
|
||||
|
||||
// SetBurndown updates the burndown chart data
|
||||
func (hc *HeaderConfig) SetBurndown(points []store.BurndownPoint) {
|
||||
hc.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestNewHeaderConfig(t *testing.T) {
|
||||
|
|
@ -17,7 +15,6 @@ func TestNewHeaderConfig(t *testing.T) {
|
|||
t.Fatal("NewHeaderConfig() returned nil")
|
||||
}
|
||||
|
||||
// Initial visibility should be true
|
||||
if !hc.IsVisible() {
|
||||
t.Error("initial IsVisible() = false, want true")
|
||||
}
|
||||
|
|
@ -26,125 +23,11 @@ func TestNewHeaderConfig(t *testing.T) {
|
|||
t.Error("initial GetUserPreference() = false, want true")
|
||||
}
|
||||
|
||||
// Initial collections should be empty
|
||||
if len(hc.GetViewActions()) != 0 {
|
||||
t.Error("initial GetViewActions() should be empty")
|
||||
}
|
||||
|
||||
if len(hc.GetPluginActions()) != 0 {
|
||||
t.Error("initial GetPluginActions() should be empty")
|
||||
}
|
||||
|
||||
if hc.GetViewName() != "" {
|
||||
t.Error("initial GetViewName() should be empty")
|
||||
}
|
||||
|
||||
if hc.GetViewDescription() != "" {
|
||||
t.Error("initial GetViewDescription() should be empty")
|
||||
}
|
||||
|
||||
if len(hc.GetBurndown()) != 0 {
|
||||
t.Error("initial GetBurndown() should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewActions(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
actions := []HeaderAction{
|
||||
{
|
||||
ID: "action1",
|
||||
Key: tcell.KeyEnter,
|
||||
Label: "Enter",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
{
|
||||
ID: "action2",
|
||||
Key: tcell.KeyEscape,
|
||||
Label: "Esc",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
}
|
||||
|
||||
hc.SetViewActions(actions)
|
||||
|
||||
got := hc.GetViewActions()
|
||||
if len(got) != 2 {
|
||||
t.Errorf("len(GetViewActions()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != "action1" {
|
||||
t.Errorf("ViewActions[0].ID = %q, want %q", got[0].ID, "action1")
|
||||
}
|
||||
|
||||
if got[1].ID != "action2" {
|
||||
t.Errorf("ViewActions[1].ID = %q, want %q", got[1].ID, "action2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_PluginActions(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
actions := []HeaderAction{
|
||||
{
|
||||
ID: "plugin1",
|
||||
Rune: '1',
|
||||
Label: "Plugin 1",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
}
|
||||
|
||||
hc.SetPluginActions(actions)
|
||||
|
||||
got := hc.GetPluginActions()
|
||||
if len(got) != 1 {
|
||||
t.Errorf("len(GetPluginActions()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != "plugin1" {
|
||||
t.Errorf("PluginActions[0].ID = %q, want %q", got[0].ID, "plugin1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewInfo(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
hc.SetViewInfo("Kanban", "Tasks moving through stages")
|
||||
|
||||
if got := hc.GetViewName(); got != "Kanban" {
|
||||
t.Errorf("GetViewName() = %q, want %q", got, "Kanban")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "Tasks moving through stages" {
|
||||
t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages")
|
||||
}
|
||||
|
||||
// update overwrites
|
||||
hc.SetViewInfo("Backlog", "Upcoming tasks")
|
||||
|
||||
if got := hc.GetViewName(); got != "Backlog" {
|
||||
t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "Upcoming tasks" {
|
||||
t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
hc.SetViewInfo("Task Detail", "")
|
||||
|
||||
if got := hc.GetViewName(); got != "Task Detail" {
|
||||
t.Errorf("GetViewName() = %q, want %q", got, "Task Detail")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "" {
|
||||
t.Errorf("GetViewDescription() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_Burndown(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
|
|
@ -177,18 +60,15 @@ func TestHeaderConfig_Burndown(t *testing.T) {
|
|||
func TestHeaderConfig_Visibility(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Default should be visible
|
||||
if !hc.IsVisible() {
|
||||
t.Error("default IsVisible() = false, want true")
|
||||
}
|
||||
|
||||
// Set invisible
|
||||
hc.SetVisible(false)
|
||||
if hc.IsVisible() {
|
||||
t.Error("IsVisible() after SetVisible(false) = true, want false")
|
||||
}
|
||||
|
||||
// Set visible again
|
||||
hc.SetVisible(true)
|
||||
if !hc.IsVisible() {
|
||||
t.Error("IsVisible() after SetVisible(true) = false, want true")
|
||||
|
|
@ -198,12 +78,10 @@ func TestHeaderConfig_Visibility(t *testing.T) {
|
|||
func TestHeaderConfig_UserPreference(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Default preference should be true
|
||||
if !hc.GetUserPreference() {
|
||||
t.Error("default GetUserPreference() = false, want true")
|
||||
}
|
||||
|
||||
// Set preference
|
||||
hc.SetUserPreference(false)
|
||||
if hc.GetUserPreference() {
|
||||
t.Error("GetUserPreference() after SetUserPreference(false) = true, want false")
|
||||
|
|
@ -218,27 +96,21 @@ func TestHeaderConfig_UserPreference(t *testing.T) {
|
|||
func TestHeaderConfig_ToggleUserPreference(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Initial state
|
||||
initialPref := hc.GetUserPreference()
|
||||
initialVisible := hc.IsVisible()
|
||||
|
||||
// Toggle
|
||||
hc.ToggleUserPreference()
|
||||
|
||||
// Preference should be toggled
|
||||
if hc.GetUserPreference() == initialPref {
|
||||
t.Error("ToggleUserPreference() did not toggle preference")
|
||||
}
|
||||
|
||||
// Visible should match new preference
|
||||
if hc.IsVisible() != hc.GetUserPreference() {
|
||||
t.Error("visible state should match preference after toggle")
|
||||
}
|
||||
|
||||
// Toggle back
|
||||
hc.ToggleUserPreference()
|
||||
|
||||
// Should return to initial state
|
||||
if hc.GetUserPreference() != initialPref {
|
||||
t.Error("ToggleUserPreference() twice did not return to initial state")
|
||||
}
|
||||
|
|
@ -258,14 +130,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
|
|||
|
||||
listenerID := hc.AddListener(listener)
|
||||
|
||||
// Test various operations trigger notification
|
||||
tests := []struct {
|
||||
name string
|
||||
action func()
|
||||
}{
|
||||
{"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }},
|
||||
{"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }},
|
||||
{"SetViewInfo", func() { hc.SetViewInfo("Test", "desc") }},
|
||||
{"SetBurndown", func() { hc.SetBurndown([]store.BurndownPoint{{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}}) }},
|
||||
{"SetVisible", func() { hc.SetVisible(false); hc.SetVisible(true) }},
|
||||
{"ToggleUserPreference", func() { hc.ToggleUserPreference() }},
|
||||
|
|
@ -284,11 +152,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// Remove listener
|
||||
hc.RemoveListener(listenerID)
|
||||
|
||||
called = false
|
||||
hc.SetViewActions([]HeaderAction{{ID: "test2"}})
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -305,8 +172,7 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
|
|||
callCount++
|
||||
})
|
||||
|
||||
// Set to current value (no change)
|
||||
hc.SetVisible(true) // Already true by default
|
||||
hc.SetVisible(true) // already true by default
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -314,7 +180,6 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
|
|||
t.Error("listener called when visibility didn't change")
|
||||
}
|
||||
|
||||
// Now change it
|
||||
hc.SetVisible(false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
|
@ -344,8 +209,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
id1 := hc.AddListener(listener1)
|
||||
id2 := hc.AddListener(listener2)
|
||||
|
||||
// Both should be notified
|
||||
hc.SetViewInfo("Test", "desc")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -355,10 +219,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Remove one
|
||||
hc.RemoveListener(id1)
|
||||
|
||||
hc.SetViewInfo("Test2", "desc2")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -368,10 +231,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Remove second
|
||||
hc.RemoveListener(id2)
|
||||
|
||||
hc.SetViewInfo("Test3", "desc3")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -387,24 +249,6 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
|
||||
done := make(chan bool)
|
||||
|
||||
// Writer goroutine - actions
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetViewActions([]HeaderAction{{ID: string(rune('a' + i%26))}})
|
||||
hc.SetPluginActions([]HeaderAction{{ID: string(rune('A' + i%26))}})
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Writer goroutine - view info
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc")
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Writer goroutine - visibility
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetVisible(i%2 == 0)
|
||||
|
|
@ -415,13 +259,15 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
done <- true
|
||||
}()
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
for range 50 {
|
||||
hc.SetBurndown(nil)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for range 100 {
|
||||
_ = hc.GetViewActions()
|
||||
_ = hc.GetPluginActions()
|
||||
_ = hc.GetViewName()
|
||||
_ = hc.GetViewDescription()
|
||||
_ = hc.GetBurndown()
|
||||
_ = hc.IsVisible()
|
||||
_ = hc.GetUserPreference()
|
||||
|
|
@ -429,30 +275,14 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for all
|
||||
for range 4 {
|
||||
for range 3 {
|
||||
<-done
|
||||
}
|
||||
|
||||
// If we get here without panic, test passes
|
||||
}
|
||||
|
||||
func TestHeaderConfig_EmptyCollections(t *testing.T) {
|
||||
func TestHeaderConfig_EmptyBurndown(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Set empty actions
|
||||
hc.SetViewActions([]HeaderAction{})
|
||||
if len(hc.GetViewActions()) != 0 {
|
||||
t.Error("GetViewActions() should return empty slice")
|
||||
}
|
||||
|
||||
// Set nil actions
|
||||
hc.SetPluginActions(nil)
|
||||
if len(hc.GetPluginActions()) != 0 {
|
||||
t.Error("GetPluginActions() with nil input should return empty slice")
|
||||
}
|
||||
|
||||
// Set empty burndown
|
||||
hc.SetBurndown([]store.BurndownPoint{})
|
||||
if len(hc.GetBurndown()) != 0 {
|
||||
t.Error("GetBurndown() should return empty slice")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ func TestNewPluginConfig(t *testing.T) {
|
|||
|
||||
if pc == nil {
|
||||
t.Fatal("NewPluginConfig() returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
if pc.GetPluginName() != "testplugin" {
|
||||
|
|
|
|||
96
model/view_context.go
Normal file
96
model/view_context.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package model
|
||||
|
||||
import "sync"
|
||||
|
||||
// ViewContext holds the active view's identity and action DTOs.
|
||||
// Subscribed to by HeaderWidget and ActionPalette. Written by RootLayout
|
||||
// via syncViewContextFromView when the active view or its actions change.
|
||||
type ViewContext struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
viewID ViewID
|
||||
viewName string
|
||||
viewDescription string
|
||||
viewActions []HeaderAction
|
||||
pluginActions []HeaderAction
|
||||
|
||||
listeners map[int]func()
|
||||
nextListener int
|
||||
}
|
||||
|
||||
func NewViewContext() *ViewContext {
|
||||
return &ViewContext{
|
||||
listeners: make(map[int]func()),
|
||||
nextListener: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFromView atomically updates all view-context fields and fires exactly one notification.
|
||||
func (vc *ViewContext) SetFromView(id ViewID, name, description string, viewActions, pluginActions []HeaderAction) {
|
||||
vc.mu.Lock()
|
||||
vc.viewID = id
|
||||
vc.viewName = name
|
||||
vc.viewDescription = description
|
||||
vc.viewActions = viewActions
|
||||
vc.pluginActions = pluginActions
|
||||
vc.mu.Unlock()
|
||||
vc.notifyListeners()
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewID() ViewID {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewID
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewName() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewName
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewDescription() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewDescription
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewActions() []HeaderAction {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewActions
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetPluginActions() []HeaderAction {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.pluginActions
|
||||
}
|
||||
|
||||
func (vc *ViewContext) AddListener(listener func()) int {
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
id := vc.nextListener
|
||||
vc.nextListener++
|
||||
vc.listeners[id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
func (vc *ViewContext) RemoveListener(id int) {
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
delete(vc.listeners, id)
|
||||
}
|
||||
|
||||
func (vc *ViewContext) notifyListeners() {
|
||||
vc.mu.RLock()
|
||||
listeners := make([]func(), 0, len(vc.listeners))
|
||||
for _, listener := range vc.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
vc.mu.RUnlock()
|
||||
|
||||
for _, listener := range listeners {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
93
model/view_context_test.go
Normal file
93
model/view_context_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestViewContext_SetFromView_SingleNotification(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var count int32
|
||||
vc.AddListener(func() { atomic.AddInt32(&count, 1) })
|
||||
|
||||
vc.SetFromView("plugin:Kanban", "Kanban", "desc", nil, nil)
|
||||
|
||||
if got := atomic.LoadInt32(&count); got != 1 {
|
||||
t.Errorf("expected exactly 1 notification, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_Getters(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
|
||||
viewActions := []HeaderAction{{ID: "edit", Label: "Edit"}}
|
||||
pluginActions := []HeaderAction{{ID: "plugin:Kanban", Label: "Kanban"}}
|
||||
|
||||
vc.SetFromView(TaskDetailViewID, "Tiki Detail", "desc", viewActions, pluginActions)
|
||||
|
||||
if vc.GetViewID() != TaskDetailViewID {
|
||||
t.Errorf("expected %v, got %v", TaskDetailViewID, vc.GetViewID())
|
||||
}
|
||||
if vc.GetViewName() != "Tiki Detail" {
|
||||
t.Errorf("expected 'Tiki Detail', got %q", vc.GetViewName())
|
||||
}
|
||||
if vc.GetViewDescription() != "desc" {
|
||||
t.Errorf("expected 'desc', got %q", vc.GetViewDescription())
|
||||
}
|
||||
if len(vc.GetViewActions()) != 1 || vc.GetViewActions()[0].ID != "edit" {
|
||||
t.Errorf("unexpected view actions: %v", vc.GetViewActions())
|
||||
}
|
||||
if len(vc.GetPluginActions()) != 1 || vc.GetPluginActions()[0].ID != "plugin:Kanban" {
|
||||
t.Errorf("unexpected plugin actions: %v", vc.GetPluginActions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_RemoveListener(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var count int32
|
||||
id := vc.AddListener(func() { atomic.AddInt32(&count, 1) })
|
||||
|
||||
vc.SetFromView("v1", "n", "d", nil, nil)
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Fatal("listener should have fired once")
|
||||
}
|
||||
|
||||
vc.RemoveListener(id)
|
||||
vc.SetFromView("v2", "n", "d", nil, nil)
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Error("listener should not fire after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_MultipleListeners(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var a, b int32
|
||||
vc.AddListener(func() { atomic.AddInt32(&a, 1) })
|
||||
vc.AddListener(func() { atomic.AddInt32(&b, 1) })
|
||||
|
||||
vc.SetFromView("v1", "n", "d", nil, nil)
|
||||
|
||||
if atomic.LoadInt32(&a) != 1 {
|
||||
t.Errorf("listener A: expected 1, got %d", atomic.LoadInt32(&a))
|
||||
}
|
||||
if atomic.LoadInt32(&b) != 1 {
|
||||
t.Errorf("listener B: expected 1, got %d", atomic.LoadInt32(&b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_ZeroValueIsEmpty(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
|
||||
if vc.GetViewID() != "" {
|
||||
t.Errorf("expected empty view ID, got %v", vc.GetViewID())
|
||||
}
|
||||
if vc.GetViewName() != "" {
|
||||
t.Errorf("expected empty view name, got %q", vc.GetViewName())
|
||||
}
|
||||
if vc.GetViewActions() != nil {
|
||||
t.Errorf("expected nil view actions, got %v", vc.GetViewActions())
|
||||
}
|
||||
if vc.GetPluginActions() != nil {
|
||||
t.Errorf("expected nil plugin actions, got %v", vc.GetPluginActions())
|
||||
}
|
||||
}
|
||||
|
|
@ -83,13 +83,18 @@ type PluginActionConfig struct {
|
|||
Key string `yaml:"key" mapstructure:"key"`
|
||||
Label string `yaml:"label" mapstructure:"label"`
|
||||
Action string `yaml:"action" mapstructure:"action"`
|
||||
Hot *bool `yaml:"hot,omitempty" mapstructure:"hot"`
|
||||
Input string `yaml:"input,omitempty" mapstructure:"input"`
|
||||
}
|
||||
|
||||
// PluginAction represents a parsed shortcut action bound to a key.
|
||||
type PluginAction struct {
|
||||
Rune rune
|
||||
Label string
|
||||
Action *ruki.ValidatedStatement
|
||||
Rune rune
|
||||
Label string
|
||||
Action *ruki.ValidatedStatement
|
||||
ShowInHeader bool
|
||||
InputType ruki.ValueType
|
||||
HasInput bool
|
||||
}
|
||||
|
||||
// PluginLaneConfig represents a lane in YAML or config definitions.
|
||||
|
|
|
|||
|
|
@ -7,32 +7,63 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// fieldNameMap maps lowercase field names to their canonical form.
|
||||
// Built once from workflow.Fields() + the "tag"→"tags" alias.
|
||||
// Rebuilt from workflow.Fields() on demand; invalidated when custom fields change.
|
||||
var (
|
||||
fieldNameMap map[string]string
|
||||
fieldNameOnce sync.Once
|
||||
fieldNameMap map[string]string
|
||||
fieldNameMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
workflow.OnCustomFieldsChanged(InvalidateFieldNameCache)
|
||||
}
|
||||
|
||||
func buildFieldNameMap() {
|
||||
fieldNameOnce.Do(func() {
|
||||
fields := workflow.Fields()
|
||||
fieldNameMap = make(map[string]string, len(fields)+1)
|
||||
for _, f := range fields {
|
||||
fieldNameMap[strings.ToLower(f.Name)] = f.Name
|
||||
}
|
||||
fieldNameMap["tag"] = "tags" // singular alias
|
||||
})
|
||||
fieldNameMu.RLock()
|
||||
if fieldNameMap != nil {
|
||||
fieldNameMu.RUnlock()
|
||||
return
|
||||
}
|
||||
fieldNameMu.RUnlock()
|
||||
|
||||
fieldNameMu.Lock()
|
||||
defer fieldNameMu.Unlock()
|
||||
if fieldNameMap != nil {
|
||||
return // double-check
|
||||
}
|
||||
rebuildFieldNameMapLocked()
|
||||
}
|
||||
|
||||
func rebuildFieldNameMapLocked() {
|
||||
fields := workflow.Fields()
|
||||
m := make(map[string]string, len(fields)+1)
|
||||
for _, f := range fields {
|
||||
m[strings.ToLower(f.Name)] = f.Name
|
||||
}
|
||||
m["tag"] = "tags" // singular alias
|
||||
fieldNameMap = m
|
||||
}
|
||||
|
||||
// InvalidateFieldNameCache clears the cached field-name lookup so the next
|
||||
// legacy conversion picks up newly registered custom fields.
|
||||
func InvalidateFieldNameCache() {
|
||||
fieldNameMu.Lock()
|
||||
fieldNameMap = nil
|
||||
fieldNameMu.Unlock()
|
||||
}
|
||||
|
||||
// normalizeFieldName returns the canonical field name for a case-insensitive input.
|
||||
// Returns the input unchanged if not found in the catalog.
|
||||
func normalizeFieldName(name string) string {
|
||||
buildFieldNameMap()
|
||||
if canonical, ok := fieldNameMap[strings.ToLower(name)]; ok {
|
||||
fieldNameMu.RLock()
|
||||
canonical, ok := fieldNameMap[strings.ToLower(name)]
|
||||
fieldNameMu.RUnlock()
|
||||
if ok {
|
||||
return canonical
|
||||
}
|
||||
return name
|
||||
|
|
@ -56,6 +87,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 +170,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.
|
||||
|
|
@ -273,7 +311,7 @@ func expandInClause(fieldName, valuesStr string) string {
|
|||
}
|
||||
// scalar field: just lowercase the IN
|
||||
canonical := normalizeFieldName(fieldName)
|
||||
return canonical + " in [" + normalizeQuotedValues(valuesStr) + "]"
|
||||
return canonical + " in [" + normalizeQuotedValues(valuesStr, canonical) + "]"
|
||||
}
|
||||
|
||||
// expandNotInClause handles field NOT IN [...] expansion.
|
||||
|
|
@ -293,11 +331,11 @@ func expandNotInClause(fieldName, valuesStr string) string {
|
|||
return "(" + strings.Join(parts, " and ") + ")"
|
||||
}
|
||||
canonical := normalizeFieldName(fieldName)
|
||||
return canonical + " not in [" + normalizeQuotedValues(valuesStr) + "]"
|
||||
return canonical + " not in [" + normalizeQuotedValues(valuesStr, canonical) + "]"
|
||||
}
|
||||
|
||||
// parseBracketValues parses a comma-separated list of values from inside [...].
|
||||
// Ensures all values are double-quoted.
|
||||
// All values are double-quoted as string literals.
|
||||
func parseBracketValues(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
|
|
@ -306,15 +344,16 @@ func parseBracketValues(s string) []string {
|
|||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// strip existing quotes and re-quote with double quotes
|
||||
p = strings.Trim(p, `"'`)
|
||||
result = append(result, `"`+p+`"`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeQuotedValues ensures all values in a comma-separated list use double quotes.
|
||||
func normalizeQuotedValues(s string) string {
|
||||
// normalizeQuotedValues ensures all values in a comma-separated list use
|
||||
// type-aware quoting based on the target field.
|
||||
func normalizeQuotedValues(s string, fieldName string) string {
|
||||
ft := lookupFieldType(fieldName)
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
|
|
@ -323,7 +362,7 @@ func normalizeQuotedValues(s string) string {
|
|||
continue
|
||||
}
|
||||
p = strings.Trim(p, `"'`)
|
||||
result = append(result, `"`+p+`"`)
|
||||
result = append(result, quoteListElementForField(p, ft))
|
||||
}
|
||||
return strings.Join(result, ", ")
|
||||
}
|
||||
|
|
@ -447,13 +486,15 @@ func convertActionSegment(seg string) (string, error) {
|
|||
if idx := strings.Index(seg, "+="); idx > 0 {
|
||||
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
|
||||
value := strings.TrimSpace(seg[idx+2:])
|
||||
converted := convertBracketValues(value)
|
||||
ft := lookupFieldType(fieldName)
|
||||
converted := convertBracketValuesTyped(value, ft)
|
||||
return fieldName + "=" + fieldName + "+" + converted, nil
|
||||
}
|
||||
if idx := strings.Index(seg, "-="); idx > 0 {
|
||||
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
|
||||
value := strings.TrimSpace(seg[idx+2:])
|
||||
converted := convertBracketValues(value)
|
||||
ft := lookupFieldType(fieldName)
|
||||
converted := convertBracketValuesTyped(value, ft)
|
||||
return fieldName + "=" + fieldName + "-" + converted, nil
|
||||
}
|
||||
|
||||
|
|
@ -473,8 +514,9 @@ func convertActionSegment(seg string) (string, error) {
|
|||
value = reSingleQuoted.ReplaceAllString(value, `"$1"`)
|
||||
// convert CURRENT_USER
|
||||
value = reCurrentUser.ReplaceAllString(value, "user()")
|
||||
// quote bare identifiers
|
||||
value = quoteIfBareIdentifier(value)
|
||||
// quote with type awareness
|
||||
ft := lookupFieldType(fieldName)
|
||||
value = quoteValueForField(value, ft)
|
||||
|
||||
return fieldName + "=" + value, nil
|
||||
}
|
||||
|
|
@ -497,14 +539,24 @@ func convertBracketValues(s string) string {
|
|||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// strip existing quotes
|
||||
p = strings.Trim(p, `"'`)
|
||||
// re-quote — all values in action brackets must be strings
|
||||
converted = append(converted, `"`+p+`"`)
|
||||
converted = append(converted, quoteListElement(p))
|
||||
}
|
||||
return "[" + strings.Join(converted, ", ") + "]"
|
||||
}
|
||||
|
||||
// quoteListElement quotes a list element value for ruki syntax.
|
||||
// Bool literals and numerics stay bare; everything else gets double-quoted.
|
||||
func quoteListElement(value string) string {
|
||||
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
if reNumeric.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
return `"` + value + `"`
|
||||
}
|
||||
|
||||
var (
|
||||
// matches function calls like now(), user(), id()
|
||||
reFunctionCall = regexp.MustCompile(`^\w+\(\)$`)
|
||||
|
|
@ -516,6 +568,7 @@ var (
|
|||
|
||||
// quoteIfBareIdentifier wraps a value in double quotes if it's a bare identifier
|
||||
// (not numeric, not a function call, not already quoted).
|
||||
// Bool literals (true/false) are left bare so the parser produces BoolLiteral nodes.
|
||||
func quoteIfBareIdentifier(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
|
|
@ -529,12 +582,111 @@ func quoteIfBareIdentifier(value string) string {
|
|||
if reFunctionCall.MatchString(value) {
|
||||
return value // function call
|
||||
}
|
||||
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
|
||||
return strings.ToLower(value) // bool literal — keep bare
|
||||
}
|
||||
if reBareIdentifier.MatchString(value) {
|
||||
return `"` + value + `"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// lookupFieldType returns a pointer to the field's ValueType, or nil if unknown.
|
||||
func lookupFieldType(fieldName string) *workflow.ValueType {
|
||||
fd, ok := workflow.Field(fieldName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
t := fd.Type
|
||||
return &t
|
||||
}
|
||||
|
||||
// quoteValueForField quotes a value according to the target field's type.
|
||||
// Only leaves bools bare for TypeBool, numbers bare for TypeInt.
|
||||
// Unknown field type defaults to quoting (safe fallback).
|
||||
func quoteValueForField(value string, ft *workflow.ValueType) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
if strings.HasPrefix(value, `"`) {
|
||||
return value
|
||||
}
|
||||
if reFunctionCall.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
if ft != nil {
|
||||
switch *ft {
|
||||
case workflow.TypeBool:
|
||||
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
case workflow.TypeInt:
|
||||
if reNumeric.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
// all other field types: quote bare identifiers, boolish and numeric strings
|
||||
if reBareIdentifier.MatchString(value) || reNumeric.MatchString(value) ||
|
||||
strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
|
||||
return `"` + value + `"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
// unknown field type: quote bare identifiers (safe fallback)
|
||||
if reBareIdentifier.MatchString(value) {
|
||||
return `"` + value + `"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// quoteListElementForField quotes a list element according to the target field's type.
|
||||
func quoteListElementForField(value string, ft *workflow.ValueType) string {
|
||||
if ft != nil {
|
||||
switch *ft {
|
||||
case workflow.TypeBool:
|
||||
if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
case workflow.TypeInt:
|
||||
if reNumeric.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return `"` + value + `"`
|
||||
}
|
||||
// unknown field type: always quote (safe fallback)
|
||||
return `"` + value + `"`
|
||||
}
|
||||
|
||||
// convertBracketValuesTyped converts a bracket-enclosed list with type-aware quoting.
|
||||
func convertBracketValuesTyped(s string, ft *workflow.ValueType) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
|
||||
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
|
||||
return quoteValueForField(s, ft)
|
||||
}
|
||||
|
||||
// for list fields, elements are always strings
|
||||
elemType := ft
|
||||
if ft != nil && (*ft == workflow.TypeListString || *ft == workflow.TypeListRef) {
|
||||
st := workflow.TypeString
|
||||
elemType = &st
|
||||
}
|
||||
|
||||
inner := s[1 : len(s)-1]
|
||||
parts := strings.Split(inner, ",")
|
||||
converted := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
p = strings.Trim(p, `"'`)
|
||||
converted = append(converted, quoteListElementForField(p, elemType))
|
||||
}
|
||||
return "[" + strings.Join(converted, ", ") + "]"
|
||||
}
|
||||
|
||||
// splitTopLevelCommas splits a string on commas, respecting [...] brackets and quotes.
|
||||
func splitTopLevelCommas(input string) ([]string, error) {
|
||||
var result []string
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
@ -612,20 +614,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)
|
||||
}
|
||||
|
|
@ -633,6 +646,7 @@ func TestLegacyWorkflowEndToEnd(t *testing.T) {
|
|||
tp, ok := p.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected TikiPlugin, got %T", p)
|
||||
return
|
||||
}
|
||||
|
||||
if tp.Name != "Board" {
|
||||
|
|
@ -703,7 +717,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 +955,215 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConvert_BoolLiteralBare(t *testing.T) {
|
||||
config.MarkRegistriesLoadedForTest()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "flag", Type: workflow.TypeBool, Custom: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
// true/false values are emitted bare for TypeBool fields
|
||||
got, err := tr.ConvertAction("flag=true")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := `update where id = id() set flag=true`
|
||||
if got != want {
|
||||
t.Errorf("ConvertAction(flag=true)\n got: %q\n want: %q", got, want)
|
||||
}
|
||||
|
||||
got, err = tr.ConvertAction("flag=false")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want = `update where id = id() set flag=false`
|
||||
if got != want {
|
||||
t.Errorf("ConvertAction(flag=false)\n got: %q\n want: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConvert_BoolInList(t *testing.T) {
|
||||
config.MarkRegistriesLoadedForTest()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "flag", Type: workflow.TypeBool, Custom: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
// bool values in lists are emitted bare for TypeBool fields
|
||||
got := tr.ConvertFilter("flag IN [true, false]")
|
||||
want := `select where flag in [true, false]`
|
||||
if got != want {
|
||||
t.Errorf("ConvertFilter(flag IN [true, false])\n got: %q\n want: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConvert_TypeAwareQuoting(t *testing.T) {
|
||||
config.MarkRegistriesLoadedForTest()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "severity", Type: workflow.TypeEnum, Custom: true, AllowedValues: []string{"low", "high", "true"}},
|
||||
{Name: "notes", Type: workflow.TypeString, Custom: true},
|
||||
{Name: "active", Type: workflow.TypeBool, Custom: true},
|
||||
{Name: "score", Type: workflow.TypeInt, Custom: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "enum field with bool-like value is quoted",
|
||||
input: "severity=true",
|
||||
want: `update where id = id() set severity="true"`,
|
||||
},
|
||||
{
|
||||
name: "string field with numeric value is quoted",
|
||||
input: "notes=42",
|
||||
want: `update where id = id() set notes="42"`,
|
||||
},
|
||||
{
|
||||
name: "bool field with true stays bare",
|
||||
input: "active=true",
|
||||
want: `update where id = id() set active=true`,
|
||||
},
|
||||
{
|
||||
name: "int field with number stays bare",
|
||||
input: "score=42",
|
||||
want: `update where id = id() set score=42`,
|
||||
},
|
||||
{
|
||||
name: "string field with bool-like value is quoted",
|
||||
input: "notes=false",
|
||||
want: `update where id = id() set notes="false"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tr.ConvertAction(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ConvertAction(%q)\n got: %q\n want: %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConvert_TypeAwareListQuoting(t *testing.T) {
|
||||
config.MarkRegistriesLoadedForTest()
|
||||
if err := workflow.RegisterCustomFields([]workflow.FieldDef{
|
||||
{Name: "labels", Type: workflow.TypeListString, Custom: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
// list<string> field: all elements must be quoted, even bool-like and numeric
|
||||
got, err := tr.ConvertAction("labels+=[true, 42, hello]")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := `update where id = id() set labels=labels+["true", "42", "hello"]`
|
||||
if got != want {
|
||||
t.Errorf("ConvertAction(labels+=[true, 42, hello])\n got: %q\n want: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConvert_UnknownFieldDefaultsToQuoting(t *testing.T) {
|
||||
config.MarkRegistriesLoadedForTest()
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
// unknown field: "true" should be quoted as safe fallback
|
||||
got, err := tr.ConvertAction("unknown_field=true")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := `update where id = id() set unknown_field="true"`
|
||||
if got != want {
|
||||
t.Errorf("ConvertAction(unknown_field=true)\n got: %q\n want: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
158
plugin/loader.go
158
plugin/loader.go
|
|
@ -13,41 +13,57 @@ import (
|
|||
|
||||
// WorkflowFile represents the YAML structure of a workflow.yaml file
|
||||
type WorkflowFile struct {
|
||||
Plugins []pluginFileConfig `yaml:"views"`
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
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 && len(wf.Views.Actions) == 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])
|
||||
}
|
||||
totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions)
|
||||
if totalConverted > 0 {
|
||||
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
|
||||
}
|
||||
|
||||
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 +92,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,7 +147,9 @@ 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.
|
||||
// Returns an error when workflow files were found but no valid plugins could be loaded.
|
||||
// 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,
|
||||
// or when type-reference errors indicate an inconsistent merged workflow.
|
||||
func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
|
||||
files := config.FindWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
|
|
@ -126,20 +158,33 @@ 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)
|
||||
}
|
||||
|
||||
// type-reference errors in views/actions are fatal merged-workflow errors,
|
||||
// not ordinary per-view parse errors that can be skipped
|
||||
if typeErrs := filterTypeErrors(allErrors); len(typeErrs) > 0 {
|
||||
return nil, fmt.Errorf("merged workflow references invalid types:\n %s\n\nIf you redefined types: in a later workflow file, update views/actions/triggers to match",
|
||||
strings.Join(typeErrs, "\n "))
|
||||
}
|
||||
|
||||
// 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 +199,87 @@ func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
|
|||
return base, nil
|
||||
}
|
||||
|
||||
// filterTypeErrors extracts errors that mention unknown type references.
|
||||
func filterTypeErrors(errs []string) []string {
|
||||
var typeErrs []string
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e, "unknown type") {
|
||||
typeErrs = append(typeErrs, e)
|
||||
}
|
||||
}
|
||||
return typeErrs
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// convertLegacyGlobalActions converts legacy action expressions in global views.actions
|
||||
// to ruki format, matching the same conversion applied to per-plugin actions.
|
||||
func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int {
|
||||
count := 0
|
||||
for i := range actions {
|
||||
action := &actions[i]
|
||||
if action.Action != "" && !isRukiAction(action.Action) {
|
||||
newAction, err := transformer.ConvertAction(action.Action)
|
||||
if err != nil {
|
||||
slog.Warn("failed to convert legacy global action, passing through",
|
||||
"error", err, "action", action.Action, "key", action.Key)
|
||||
continue
|
||||
}
|
||||
slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key)
|
||||
action.Action = newAction
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -29,6 +31,7 @@ func TestParsePluginConfig_FullyInline(t *testing.T) {
|
|||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
return
|
||||
}
|
||||
|
||||
if tp.Name != "Inline Test" {
|
||||
|
|
@ -72,6 +75,7 @@ func TestParsePluginConfig_Minimal(t *testing.T) {
|
|||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
return
|
||||
}
|
||||
|
||||
if tp.Name != "Minimal" {
|
||||
|
|
@ -140,7 +144,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 +178,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 +204,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 +257,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 +302,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 +322,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 +340,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 +368,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)
|
||||
}
|
||||
|
|
@ -376,6 +380,7 @@ func TestLoadPluginsFromFile_DokiConfigIndex(t *testing.T) {
|
|||
dp, ok := plugins[1].(*DokiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected DokiPlugin, got %T", plugins[1])
|
||||
return
|
||||
}
|
||||
if dp.ConfigIndex != 1 {
|
||||
t.Errorf("expected DokiPlugin ConfigIndex 1, got %d", dp.ConfigIndex)
|
||||
|
|
@ -416,6 +421,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"`
|
||||
|
|
|
|||
|
|
@ -206,18 +206,45 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
|
|||
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
|
||||
}
|
||||
|
||||
actionStmt, err := parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
if actionStmt.IsSelect() {
|
||||
return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, or DELETE — not SELECT", i, cfg.Key)
|
||||
var (
|
||||
actionStmt *ruki.ValidatedStatement
|
||||
inputType ruki.ValueType
|
||||
hasInput bool
|
||||
)
|
||||
|
||||
if cfg.Input != "" {
|
||||
typ, err := ruki.ParseScalarTypeName(cfg.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("action %d (key %q) input: %w", i, cfg.Key, err)
|
||||
}
|
||||
actionStmt, err = parser.ParseAndValidateStatementWithInput(cfg.Action, ruki.ExecutorRuntimePlugin, typ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
if !actionStmt.UsesInputBuiltin() {
|
||||
return nil, fmt.Errorf("action %d (key %q) declares 'input: %s' but does not use input()", i, cfg.Key, cfg.Input)
|
||||
}
|
||||
inputType = typ
|
||||
hasInput = true
|
||||
} else {
|
||||
var err error
|
||||
actionStmt, err = parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
showInHeader := true
|
||||
if cfg.Hot != nil {
|
||||
showInHeader = *cfg.Hot
|
||||
}
|
||||
actions = append(actions, PluginAction{
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: actionStmt,
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: actionStmt,
|
||||
ShowInHeader: showInHeader,
|
||||
InputType: inputType,
|
||||
HasInput: hasInput,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ background: "#0000ff"
|
|||
tikiPlugin, ok := plugin.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", plugin)
|
||||
return
|
||||
}
|
||||
|
||||
if tikiPlugin.GetName() != "Test Plugin" {
|
||||
|
|
@ -495,17 +496,37 @@ 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 SELECT") {
|
||||
t.Errorf("expected 'not 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_PipeAcceptedAsAction(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "c", Label: "Copy ID", Action: `select id where id = id() | run("echo $1")`},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("expected pipe action to be accepted, got error: %v", err)
|
||||
}
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(actions))
|
||||
}
|
||||
if !actions[0].Action.IsPipe() {
|
||||
t.Error("expected IsPipe() = true for pipe action")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -725,6 +746,7 @@ foreground: "#00ff00"
|
|||
dokiPlugin, ok := plugin.(*DokiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected DokiPlugin, got %T", plugin)
|
||||
return
|
||||
}
|
||||
|
||||
if dokiPlugin.GetName() != "Doc Plugin" {
|
||||
|
|
@ -739,3 +761,168 @@ foreground: "#00ff00"
|
|||
t.Errorf("Expected URL, got %q", dokiPlugin.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotDefault(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].ShowInHeader {
|
||||
t.Error("absent hot should default to ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotExplicitFalse(t *testing.T) {
|
||||
parser := testParser()
|
||||
hotFalse := false
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if actions[0].ShowInHeader {
|
||||
t.Error("hot: false should set ShowInHeader=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotExplicitTrue(t *testing.T) {
|
||||
parser := testParser()
|
||||
hotTrue := true
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].ShowInHeader {
|
||||
t.Error("hot: true should set ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) {
|
||||
yamlData := []byte(`
|
||||
name: Test
|
||||
key: T
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: select where status = "backlog"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Board"
|
||||
action: update where id = id() set status = "ready"
|
||||
hot: false
|
||||
- key: "a"
|
||||
label: "Assign"
|
||||
action: update where id = id() set assignee = user()
|
||||
`)
|
||||
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
tiki, ok := p.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected TikiPlugin, got %T", p)
|
||||
}
|
||||
|
||||
if tiki.Actions[0].ShowInHeader {
|
||||
t.Error("action with hot: false should have ShowInHeader=false")
|
||||
}
|
||||
if !tiki.Actions[1].ShowInHeader {
|
||||
t.Error("action without hot should default to ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputValid(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "string"},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(actions))
|
||||
}
|
||||
if !actions[0].HasInput {
|
||||
t.Error("expected HasInput=true")
|
||||
}
|
||||
if actions[0].InputType != ruki.ValueString {
|
||||
t.Errorf("expected InputType=ValueString, got %d", actions[0].InputType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputIntValid(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "p", Label: "Set points", Action: `update where id = id() set points=input()`, Input: "int"},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].HasInput {
|
||||
t.Error("expected HasInput=true")
|
||||
}
|
||||
if actions[0].InputType != ruki.ValueInt {
|
||||
t.Errorf("expected InputType=ValueInt, got %d", actions[0].InputType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputTypeMismatch(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "int"},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input type mismatch (int into string field)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputWithoutInputFunc(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`, Input: "string"},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error: input: declared but input() not used")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not use input()") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_InputUnsupportedType(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Assign to", Action: `update where id = id() set assignee=input()`, Input: "enum"},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported input type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_NoInputField_NoHasInput(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "a", Label: "Ready", Action: `update where id = id() set status="ready"`},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if actions[0].HasInput {
|
||||
t.Error("expected HasInput=false for action without input: field")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
ruki/ast.go
20
ruki/ast.go
|
|
@ -13,13 +13,25 @@ type Statement struct {
|
|||
Delete *DeleteStmt
|
||||
}
|
||||
|
||||
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...]".
|
||||
// SelectStmt represents "select [fields] [where <condition>] [order by ...] [limit N] [| run(...) | clipboard()]".
|
||||
type SelectStmt struct {
|
||||
Fields []string // nil = all ("select" or "select *"); non-nil = specific fields
|
||||
Where Condition // nil = select all
|
||||
OrderBy []OrderByClause // nil = unordered
|
||||
Limit *int // nil = no limit; positive = max result count
|
||||
Pipe *PipeAction // optional pipe suffix: "| run(...)" or "| clipboard()"
|
||||
}
|
||||
|
||||
// PipeAction is a discriminated union for pipe targets.
|
||||
// Exactly one variant is non-nil.
|
||||
type PipeAction struct {
|
||||
Run *RunAction
|
||||
Clipboard *ClipboardAction
|
||||
}
|
||||
|
||||
// ClipboardAction represents "clipboard()" as a pipe target.
|
||||
type ClipboardAction struct{}
|
||||
|
||||
// CreateStmt represents "create <field>=<value>...".
|
||||
type CreateStmt struct {
|
||||
Assignments []Assignment
|
||||
|
|
@ -180,6 +192,11 @@ type BinaryExpr struct {
|
|||
Right Expr
|
||||
}
|
||||
|
||||
// BoolLiteral represents a bare true/false identifier lowered from FieldRef.
|
||||
type BoolLiteral struct {
|
||||
Value bool
|
||||
}
|
||||
|
||||
// SubQuery represents "select [where <condition>]" used inside count().
|
||||
type SubQuery struct {
|
||||
Where Condition // nil = select all
|
||||
|
|
@ -195,6 +212,7 @@ func (*ListLiteral) exprNode() {}
|
|||
func (*EmptyLiteral) exprNode() {}
|
||||
func (*FunctionCall) exprNode() {}
|
||||
func (*BinaryExpr) exprNode() {}
|
||||
func (*BoolLiteral) exprNode() {}
|
||||
func (*SubQuery) exprNode() {}
|
||||
|
||||
// --- order by ---
|
||||
|
|
|
|||
299
ruki/executor.go
299
ruki/executor.go
|
|
@ -34,10 +34,25 @@ func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime)
|
|||
// Result holds the output of executing a statement.
|
||||
// Exactly one variant is non-nil.
|
||||
type Result struct {
|
||||
Select *TaskProjection
|
||||
Update *UpdateResult
|
||||
Create *CreateResult
|
||||
Delete *DeleteResult
|
||||
Select *TaskProjection
|
||||
Update *UpdateResult
|
||||
Create *CreateResult
|
||||
Delete *DeleteResult
|
||||
Pipe *PipeResult
|
||||
Clipboard *ClipboardResult
|
||||
}
|
||||
|
||||
// ClipboardResult holds the row data from a clipboard-piped select.
|
||||
// The service layer writes these to the system clipboard.
|
||||
type ClipboardResult struct {
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
// PipeResult holds the shell command and per-row positional args from a piped select.
|
||||
// The ruki executor builds this; the service layer performs the actual shell execution.
|
||||
type PipeResult struct {
|
||||
Command string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
// UpdateResult holds the cloned, mutated tasks produced by an UPDATE statement.
|
||||
|
|
@ -138,6 +153,19 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result,
|
|||
e.sortTasks(filtered, sel.OrderBy)
|
||||
}
|
||||
|
||||
if sel.Limit != nil && *sel.Limit < len(filtered) {
|
||||
filtered = filtered[:*sel.Limit]
|
||||
}
|
||||
|
||||
if sel.Pipe != nil {
|
||||
switch {
|
||||
case sel.Pipe.Run != nil:
|
||||
return e.buildPipeResult(sel.Pipe.Run, sel.Fields, filtered, tasks)
|
||||
case sel.Pipe.Clipboard != nil:
|
||||
return e.buildClipboardResult(sel.Fields, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Select: &TaskProjection{
|
||||
Tasks: filtered,
|
||||
|
|
@ -146,6 +174,53 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result,
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*task.Task, allTasks []*task.Task) (*Result, error) {
|
||||
// evaluate command once with a nil-sentinel task — validation ensures no field refs
|
||||
cmdVal, err := e.evalExpr(pipe.Command, nil, allTasks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipe command: %w", err)
|
||||
}
|
||||
cmdStr, ok := cmdVal.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal)
|
||||
}
|
||||
|
||||
rows := e.buildFieldRows(fields, matched)
|
||||
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
|
||||
}
|
||||
|
||||
func (e *Executor) buildClipboardResult(fields []string, matched []*task.Task) (*Result, error) {
|
||||
rows := e.buildFieldRows(fields, matched)
|
||||
return &Result{Clipboard: &ClipboardResult{Rows: rows}}, nil
|
||||
}
|
||||
|
||||
// buildFieldRows extracts the requested fields from matched tasks as string rows.
|
||||
// Shared by both run() and clipboard() pipe targets.
|
||||
func (e *Executor) buildFieldRows(fields []string, matched []*task.Task) [][]string {
|
||||
rows := make([][]string, len(matched))
|
||||
for i, t := range matched {
|
||||
row := make([]string, len(fields))
|
||||
for j, f := range fields {
|
||||
row[j] = pipeArgString(e.extractField(t, f))
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// pipeArgString space-joins list fields (tags, dependsOn) instead of using Go's
|
||||
// default fmt.Sprint which produces "[a b c]" with brackets.
|
||||
func pipeArgString(val interface{}) string {
|
||||
if list, ok := val.([]interface{}); ok {
|
||||
parts := make([]string, len(list))
|
||||
for i, elem := range list {
|
||||
parts[i] = normalizeToString(elem)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return normalizeToString(val)
|
||||
}
|
||||
|
||||
func (e *Executor) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result, error) {
|
||||
matched, err := e.filterTasks(upd.Where, tasks)
|
||||
if err != nil {
|
||||
|
|
@ -309,7 +384,7 @@ func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
|
|||
t.DependsOn = nil
|
||||
return nil
|
||||
}
|
||||
t.DependsOn = toStringSlice(val)
|
||||
t.DependsOn = normalizeRefList(toStringSlice(val))
|
||||
|
||||
case "due":
|
||||
if val == nil {
|
||||
|
|
@ -348,7 +423,22 @@ func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
|
|||
t.Assignee = s
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown field %q", name)
|
||||
fs, ok := e.schema.Field(name)
|
||||
if !ok || !fs.Custom {
|
||||
return fmt.Errorf("unknown field %q", name)
|
||||
}
|
||||
if val == nil {
|
||||
delete(t.CustomFields, name)
|
||||
return nil
|
||||
}
|
||||
coerced, err := coerceCustomFieldValue(fs, val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q: %w", name, err)
|
||||
}
|
||||
if t.CustomFields == nil {
|
||||
t.CustomFields = make(map[string]interface{})
|
||||
}
|
||||
t.CustomFields[name] = coerced
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -365,6 +455,73 @@ func toStringSlice(val interface{}) []string {
|
|||
return result
|
||||
}
|
||||
|
||||
func coerceCustomFieldValue(fs FieldSpec, val interface{}) (interface{}, error) {
|
||||
switch fs.Type {
|
||||
case ValueString:
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected string, got %T", val)
|
||||
}
|
||||
return s, nil
|
||||
case ValueInt:
|
||||
n, ok := val.(int)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected int, got %T", val)
|
||||
}
|
||||
return n, nil
|
||||
case ValueBool:
|
||||
if b, ok := val.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
if s, ok := val.(string); ok {
|
||||
if b, err := parseBoolString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("expected bool, got %T", val)
|
||||
case ValueTimestamp:
|
||||
tv, ok := val.(time.Time)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected time.Time, got %T", val)
|
||||
}
|
||||
return tv, nil
|
||||
case ValueEnum:
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected string, got %T", val)
|
||||
}
|
||||
for _, av := range fs.AllowedValues {
|
||||
if strings.EqualFold(av, s) {
|
||||
return av, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("invalid enum value %q", s)
|
||||
case ValueListString:
|
||||
return toStringSlice(val), nil
|
||||
case ValueListRef:
|
||||
raw := toStringSlice(val)
|
||||
return normalizeRefList(raw), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported custom field type")
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeRefList trims whitespace and uppercases task ID references.
|
||||
func normalizeRefList(ss []string) []string {
|
||||
var result []string
|
||||
for _, s := range ss {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, strings.ToUpper(s))
|
||||
}
|
||||
if result == nil {
|
||||
result = []string{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- filtering ---
|
||||
|
||||
func (e *Executor) filterTasks(where Condition, tasks []*task.Task) ([]*task.Task, error) {
|
||||
|
|
@ -468,10 +625,17 @@ func (e *Executor) evalIn(c *InExpr, t *task.Task, allTasks []*task.Task) (bool,
|
|||
|
||||
// list membership mode
|
||||
if list, ok := collVal.([]interface{}); ok {
|
||||
// unset field (nil) is not a member of any list
|
||||
if val == nil {
|
||||
return c.Negated, nil
|
||||
}
|
||||
valStr := normalizeToString(val)
|
||||
// use case-insensitive comparison for enum-like fields
|
||||
foldCase := isEnumLikeField(e.exprFieldType(c.Value))
|
||||
found := false
|
||||
for _, elem := range list {
|
||||
if normalizeToString(elem) == valStr {
|
||||
elemStr := normalizeToString(elem)
|
||||
if foldCase && strings.EqualFold(valStr, elemStr) || !foldCase && valStr == elemStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
|
@ -556,13 +720,15 @@ func (e *Executor) evalQuantifier(q *QuantifierExpr, t *task.Task, allTasks []*t
|
|||
func (e *Executor) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
switch expr := expr.(type) {
|
||||
case *FieldRef:
|
||||
return extractField(t, expr.Name), nil
|
||||
return e.extractField(t, expr.Name), nil
|
||||
case *QualifiedRef:
|
||||
return nil, fmt.Errorf("qualified references (old./new.) are not supported in standalone SELECT")
|
||||
case *StringLiteral:
|
||||
return expr.Value, nil
|
||||
case *IntLiteral:
|
||||
return expr.Value, nil
|
||||
case *BoolLiteral:
|
||||
return expr.Value, nil
|
||||
case *DateLiteral:
|
||||
return expr.Value, nil
|
||||
case *DurationLiteral:
|
||||
|
|
@ -614,6 +780,8 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
|
|||
return e.evalNextDate(fc, t, allTasks)
|
||||
case "blocks":
|
||||
return e.evalBlocks(fc, t, allTasks)
|
||||
case "input":
|
||||
return e.evalInput()
|
||||
case "call":
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
default:
|
||||
|
|
@ -621,6 +789,13 @@ func (e *Executor) evalFunctionCall(fc *FunctionCall, t *task.Task, allTasks []*
|
|||
}
|
||||
}
|
||||
|
||||
func (e *Executor) evalInput() (interface{}, error) {
|
||||
if !e.currentInput.HasInput {
|
||||
return nil, &MissingInputValueError{}
|
||||
}
|
||||
return e.currentInput.InputValue, nil
|
||||
}
|
||||
|
||||
func (e *Executor) evalID() (interface{}, error) {
|
||||
if e.runtime.Mode != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
|
|
@ -779,8 +954,8 @@ func subtractValues(left, right interface{}) (interface{}, error) {
|
|||
func (e *Executor) sortTasks(tasks []*task.Task, clauses []OrderByClause) {
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
for _, c := range clauses {
|
||||
vi := extractField(tasks[i], c.Field)
|
||||
vj := extractField(tasks[j], c.Field)
|
||||
vi := e.extractField(tasks[i], c.Field)
|
||||
vj := e.extractField(tasks[j], c.Field)
|
||||
cmp := compareForSort(vi, vj)
|
||||
if cmp == 0 {
|
||||
continue
|
||||
|
|
@ -812,6 +987,15 @@ func compareForSort(a, b interface{}) int {
|
|||
case string:
|
||||
bv, _ := b.(string)
|
||||
return strings.Compare(av, bv)
|
||||
case bool:
|
||||
bv, _ := b.(bool)
|
||||
if av == bv {
|
||||
return 0
|
||||
}
|
||||
if !av && bv {
|
||||
return -1 // false < true
|
||||
}
|
||||
return 1
|
||||
case task.Status:
|
||||
bv, _ := b.(task.Status)
|
||||
return strings.Compare(string(av), string(bv))
|
||||
|
|
@ -858,7 +1042,7 @@ func compareInts(a, b int) int {
|
|||
|
||||
func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) {
|
||||
if left == nil || right == nil {
|
||||
return compareWithNil(left, right, op)
|
||||
return compareWithNil(left, right, op, leftExpr, rightExpr)
|
||||
}
|
||||
|
||||
if leftList, ok := left.([]interface{}); ok {
|
||||
|
|
@ -871,6 +1055,20 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
|
|||
if rb, ok := right.(bool); ok {
|
||||
return compareBools(lb, rb, op)
|
||||
}
|
||||
// resilience: coerce string-encoded bool on right side
|
||||
if rs, ok := right.(string); ok {
|
||||
if rb, err := parseBoolString(rs); err == nil {
|
||||
return compareBools(lb, rb, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rb, ok := right.(bool); ok {
|
||||
// resilience: coerce string-encoded bool on left side
|
||||
if ls, ok := left.(string); ok {
|
||||
if lb, err := parseBoolString(ls); err == nil {
|
||||
return compareBools(lb, rb, op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compType := e.resolveComparisonType(leftExpr, rightExpr)
|
||||
|
|
@ -886,6 +1084,10 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
|
|||
ls := e.normalizeTypeStr(normalizeToString(left))
|
||||
rs := e.normalizeTypeStr(normalizeToString(right))
|
||||
return compareStrings(ls, rs, op)
|
||||
case ValueEnum:
|
||||
ls := strings.ToLower(normalizeToString(left))
|
||||
rs := strings.ToLower(normalizeToString(right))
|
||||
return compareStrings(ls, rs, op)
|
||||
}
|
||||
|
||||
switch lv := left.(type) {
|
||||
|
|
@ -923,10 +1125,10 @@ func (e *Executor) compareValues(left, right interface{}, op string, leftExpr, r
|
|||
// resolveComparisonType returns the dominant field type for a comparison,
|
||||
// checking both sides for enum/id fields that need special handling.
|
||||
func (e *Executor) resolveComparisonType(left, right Expr) ValueType {
|
||||
if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType {
|
||||
if t := e.exprFieldType(left); t == ValueID || t == ValueStatus || t == ValueTaskType || t == ValueEnum {
|
||||
return t
|
||||
}
|
||||
if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType {
|
||||
if t := e.exprFieldType(right); t == ValueID || t == ValueStatus || t == ValueTaskType || t == ValueEnum {
|
||||
return t
|
||||
}
|
||||
return -1
|
||||
|
|
@ -954,6 +1156,13 @@ func (e *Executor) exprFieldType(expr Expr) ValueType {
|
|||
return fs.Type
|
||||
}
|
||||
|
||||
// isEnumLikeField returns true for field types that use case-insensitive
|
||||
// comparison in equality checks and should also use it for in/not-in.
|
||||
// Includes ValueBool so that "True"/"true"/"TRUE" all match in bool in-lists.
|
||||
func isEnumLikeField(t ValueType) bool {
|
||||
return t == ValueEnum || t == ValueStatus || t == ValueTaskType || t == ValueID || t == ValueBool
|
||||
}
|
||||
|
||||
func (e *Executor) normalizeStatusStr(s string) string {
|
||||
if norm, ok := e.schema.NormalizeStatus(s); ok {
|
||||
return norm
|
||||
|
|
@ -968,16 +1177,32 @@ func (e *Executor) normalizeTypeStr(s string) string {
|
|||
return s
|
||||
}
|
||||
|
||||
func compareWithNil(left, right interface{}, op string) (bool, error) {
|
||||
// treat nil as empty; treat zero-valued non-nil as also matching empty
|
||||
leftEmpty := isZeroValue(left)
|
||||
rightEmpty := isZeroValue(right)
|
||||
bothEmpty := leftEmpty && rightEmpty
|
||||
func compareWithNil(left, right interface{}, op string, leftExpr, rightExpr Expr) (bool, error) {
|
||||
// when comparing against EmptyLiteral, use zero-value semantics:
|
||||
// nil and typed zeros both count as "empty"
|
||||
_, leftIsEmpty := leftExpr.(*EmptyLiteral)
|
||||
_, rightIsEmpty := rightExpr.(*EmptyLiteral)
|
||||
if leftIsEmpty || rightIsEmpty {
|
||||
leftEmpty := isZeroValue(left)
|
||||
rightEmpty := isZeroValue(right)
|
||||
bothEmpty := leftEmpty && rightEmpty
|
||||
switch op {
|
||||
case "=":
|
||||
return bothEmpty, nil
|
||||
case "!=":
|
||||
return !bothEmpty, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// concrete comparison: nil (unset field) only equals nil
|
||||
bothNil := left == nil && right == nil
|
||||
switch op {
|
||||
case "=":
|
||||
return bothEmpty, nil
|
||||
return bothNil, nil
|
||||
case "!=":
|
||||
return !bothEmpty, nil
|
||||
return !bothNil, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
|
|
@ -1111,7 +1336,7 @@ func compareDurations(a, b time.Duration, op string) (bool, error) {
|
|||
|
||||
// --- field extraction ---
|
||||
|
||||
func extractField(t *task.Task, name string) interface{} {
|
||||
func (e *Executor) extractField(t *task.Task, name string) interface{} {
|
||||
switch name {
|
||||
case "id":
|
||||
return t.ID
|
||||
|
|
@ -1144,12 +1369,44 @@ func extractField(t *task.Task, name string) interface{} {
|
|||
case "updatedAt":
|
||||
return t.UpdatedAt
|
||||
default:
|
||||
fs, ok := e.schema.Field(name)
|
||||
if !ok || !fs.Custom {
|
||||
return nil
|
||||
}
|
||||
if t.CustomFields != nil {
|
||||
if v, exists := t.CustomFields[name]; exists {
|
||||
if fs.Type == ValueListString || fs.Type == ValueListRef {
|
||||
if ss, ok := v.([]string); ok {
|
||||
return toInterfaceSlice(ss)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
// unset custom field: list types return empty list (consistent
|
||||
// with built-in tags/dependsOn), scalars return nil
|
||||
if fs.Type == ValueListString || fs.Type == ValueListRef {
|
||||
return []interface{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// parseBoolString converts a string "true"/"false" (case-insensitive) to a bool.
|
||||
// Returns an error for any other string.
|
||||
func parseBoolString(s string) (bool, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("not a bool string: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func toInterfaceSlice(ss []string) []interface{} {
|
||||
if ss == nil {
|
||||
return []interface{}{}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ func (r ExecutorRuntime) normalize() ExecutorRuntime {
|
|||
type ExecutionInput struct {
|
||||
SelectedTaskID string
|
||||
CreateTemplate *task.Task
|
||||
InputValue interface{} // value returned by input() builtin
|
||||
HasInput bool // distinguishes nil from unset
|
||||
}
|
||||
|
||||
// RuntimeMismatchError reports execution with a wrapper validated for a
|
||||
|
|
@ -68,6 +70,13 @@ func (e *MissingCreateTemplateError) Error() string {
|
|||
return "create template is required for create execution"
|
||||
}
|
||||
|
||||
// MissingInputValueError reports execution of input() without a provided value.
|
||||
type MissingInputValueError struct{}
|
||||
|
||||
func (e *MissingInputValueError) Error() string {
|
||||
return "input value is required when input() is used"
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrRuntimeMismatch is used with errors.Is for runtime mismatch failures.
|
||||
ErrRuntimeMismatch = errors.New("runtime mismatch")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package ruki
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -299,6 +300,72 @@ func TestExecuteSelectNoOrderByPreservesInputOrder(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- limit ---
|
||||
|
||||
func TestExecuteSelectLimit(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
tasks := makeTasks()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
"limit fewer than available",
|
||||
"select order by priority limit 2",
|
||||
[]string{"TIKI-000002", "TIKI-000001"},
|
||||
},
|
||||
{
|
||||
"limit equal to count",
|
||||
"select limit 4",
|
||||
[]string{"TIKI-000001", "TIKI-000002", "TIKI-000003", "TIKI-000004"},
|
||||
},
|
||||
{
|
||||
"limit greater than count",
|
||||
"select limit 100",
|
||||
[]string{"TIKI-000001", "TIKI-000002", "TIKI-000003", "TIKI-000004"},
|
||||
},
|
||||
{
|
||||
"limit 1",
|
||||
"select order by priority limit 1",
|
||||
[]string{"TIKI-000002"},
|
||||
},
|
||||
{
|
||||
"limit with where",
|
||||
"select where priority <= 2 order by priority limit 1",
|
||||
[]string{"TIKI-000002"},
|
||||
},
|
||||
{
|
||||
"limit without order by",
|
||||
"select limit 2",
|
||||
[]string{"TIKI-000001", "TIKI-000002"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != len(tt.wantIDs) {
|
||||
t.Fatalf("expected %d tasks, got %d", len(tt.wantIDs), len(result.Select.Tasks))
|
||||
}
|
||||
for i, wantID := range tt.wantIDs {
|
||||
if result.Select.Tasks[i].ID != wantID {
|
||||
t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- enum normalization ---
|
||||
|
||||
func TestExecuteEnumNormalization(t *testing.T) {
|
||||
|
|
@ -1398,6 +1465,7 @@ func TestExecuteSortByStatusTypeRecurrence(t *testing.T) {
|
|||
// --- extractField additional branches ---
|
||||
|
||||
func TestExtractFieldAllFields(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
tk := &task.Task{
|
||||
ID: "T1", Title: "hi", Description: "desc", Status: "ready",
|
||||
Type: "bug", Priority: 1, Points: 3, Tags: []string{"a"},
|
||||
|
|
@ -1412,12 +1480,12 @@ func TestExtractFieldAllFields(t *testing.T) {
|
|||
"createdBy", "createdAt", "updatedAt",
|
||||
}
|
||||
for _, f := range fields {
|
||||
v := extractField(tk, f)
|
||||
v := e.extractField(tk, f)
|
||||
if v == nil {
|
||||
t.Errorf("extractField(%q) returned nil", f)
|
||||
}
|
||||
}
|
||||
if v := extractField(tk, "nonexistent"); v != nil {
|
||||
if v := e.extractField(tk, "nonexistent"); v != nil {
|
||||
t.Errorf("extractField(nonexistent) should be nil, got %v", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -1478,6 +1546,7 @@ func TestDurationLiteralUnknownUnitError(t *testing.T) {
|
|||
add, ok := cmp.Right.(*BinaryExpr)
|
||||
if !ok {
|
||||
t.Fatal("expected *BinaryExpr")
|
||||
return
|
||||
}
|
||||
add.Right = &DurationLiteral{Value: 1, Unit: "bogus"}
|
||||
|
||||
|
|
@ -3738,3 +3807,603 @@ func TestExecuteUnsupportedStatementType(t *testing.T) {
|
|||
t.Errorf("expected 'unsupported statement type' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- custom field executor tests ---
|
||||
|
||||
func newCustomExecutor() *Executor {
|
||||
return NewExecutor(customTestSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
|
||||
}
|
||||
|
||||
func newCustomParser2() *Parser {
|
||||
return NewParser(customTestSchema{})
|
||||
}
|
||||
|
||||
func TestExecutor_CustomFieldExtraction(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
|
||||
// task with no custom fields — extractField returns nil for unset fields
|
||||
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
|
||||
|
||||
if v := e.extractField(tk, "notes"); v != nil {
|
||||
t.Errorf("notes unset: got %v, want nil", v)
|
||||
}
|
||||
if v := e.extractField(tk, "score"); v != nil {
|
||||
t.Errorf("score unset: got %v, want nil", v)
|
||||
}
|
||||
if v := e.extractField(tk, "flag"); v != nil {
|
||||
t.Errorf("flag unset: got %v, want nil", v)
|
||||
}
|
||||
if v := e.extractField(tk, "severity"); v != nil {
|
||||
t.Errorf("severity unset: got %v, want nil", v)
|
||||
}
|
||||
|
||||
// task with custom fields set — returns actual values
|
||||
tk2 := &task.Task{
|
||||
ID: "T2", Title: "y", Status: "ready",
|
||||
CustomFields: map[string]interface{}{
|
||||
"notes": "hello",
|
||||
"score": 42,
|
||||
"flag": true,
|
||||
"severity": "high",
|
||||
},
|
||||
}
|
||||
if v := e.extractField(tk2, "notes"); v != "hello" {
|
||||
t.Errorf("notes: got %v, want hello", v)
|
||||
}
|
||||
if v := e.extractField(tk2, "score"); v != 42 {
|
||||
t.Errorf("score: got %v, want 42", v)
|
||||
}
|
||||
if v := e.extractField(tk2, "flag"); v != true {
|
||||
t.Errorf("flag: got %v, want true", v)
|
||||
}
|
||||
if v := e.extractField(tk2, "severity"); v != "high" {
|
||||
t.Errorf("severity: got %v, want high", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_CustomFieldSetAndGet(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
|
||||
|
||||
if err := e.setField(tk, "notes", "hello"); err != nil {
|
||||
t.Fatalf("setField notes: %v", err)
|
||||
}
|
||||
if err := e.setField(tk, "score", 42); err != nil {
|
||||
t.Fatalf("setField score: %v", err)
|
||||
}
|
||||
if err := e.setField(tk, "flag", true); err != nil {
|
||||
t.Fatalf("setField flag: %v", err)
|
||||
}
|
||||
if err := e.setField(tk, "severity", "high"); err != nil {
|
||||
t.Fatalf("setField severity: %v", err)
|
||||
}
|
||||
|
||||
if v := e.extractField(tk, "notes"); v != "hello" {
|
||||
t.Errorf("notes: got %v, want hello", v)
|
||||
}
|
||||
if v := e.extractField(tk, "score"); v != 42 {
|
||||
t.Errorf("score: got %v, want 42", v)
|
||||
}
|
||||
if v := e.extractField(tk, "flag"); v != true {
|
||||
t.Errorf("flag: got %v, want true", v)
|
||||
}
|
||||
if v := e.extractField(tk, "severity"); v != "high" {
|
||||
t.Errorf("severity: got %v, want high", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_UnsetCustomListFieldReturnsEmptyList(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
|
||||
// task with no custom fields at all
|
||||
tk := &task.Task{ID: "T1", Title: "x", Status: "ready"}
|
||||
|
||||
// unset custom list fields should return []interface{}{}, not nil
|
||||
labelsVal := e.extractField(tk, "labels")
|
||||
if labelsVal == nil {
|
||||
t.Fatal("unset labels should return empty list, got nil")
|
||||
}
|
||||
labels, ok := labelsVal.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("labels type = %T, want []interface{}", labelsVal)
|
||||
}
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("labels len = %d, want 0", len(labels))
|
||||
}
|
||||
|
||||
relVal := e.extractField(tk, "related")
|
||||
if relVal == nil {
|
||||
t.Fatal("unset related should return empty list, got nil")
|
||||
}
|
||||
related, ok := relVal.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("related type = %T, want []interface{}", relVal)
|
||||
}
|
||||
if len(related) != 0 {
|
||||
t.Errorf("related len = %d, want 0", len(related))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_UnsetCustomListField_InExprWorks(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
// task with no "labels" set — in-expr should work without error
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready"},
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`select where "bug" in labels`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute should not error on unset custom list: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 0 {
|
||||
t.Errorf("expected 0 matching tasks, got %d", len(result.Select.Tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_UnsetCustomListField_AddWorks(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
// task with no "labels" set — update adding to list should work
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready"},
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`update where id = "T1" set labels = labels + ["new"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute should not error on unset custom list add: %v", err)
|
||||
}
|
||||
if len(result.Update.Updated) != 1 {
|
||||
t.Fatalf("expected 1 updated task, got %d", len(result.Update.Updated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_CustomFieldNilDelete(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
tk := &task.Task{
|
||||
ID: "T1", Title: "x", Status: "ready",
|
||||
CustomFields: map[string]interface{}{
|
||||
"notes": "hello",
|
||||
"score": 42,
|
||||
},
|
||||
}
|
||||
|
||||
if err := e.setField(tk, "notes", nil); err != nil {
|
||||
t.Fatalf("setField nil: %v", err)
|
||||
}
|
||||
if _, exists := tk.CustomFields["notes"]; exists {
|
||||
t.Error("notes should be deleted from CustomFields after nil set")
|
||||
}
|
||||
// extractField should return nil for deleted (unset) field
|
||||
if v := e.extractField(tk, "notes"); v != nil {
|
||||
t.Errorf("notes after delete: got %v, want nil", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_SelectWhereCustomEnum(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "high"}},
|
||||
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
|
||||
{ID: "T4", Title: "D", Status: "ready"}, // no severity set
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`select where severity = "low"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 2 {
|
||||
ids := make([]string, len(result.Select.Tasks))
|
||||
for i, tk := range result.Select.Tasks {
|
||||
ids[i] = tk.ID
|
||||
}
|
||||
t.Fatalf("expected 2 tasks, got %d: %v", len(result.Select.Tasks), ids)
|
||||
}
|
||||
if result.Select.Tasks[0].ID != "T1" || result.Select.Tasks[1].ID != "T3" {
|
||||
t.Errorf("expected T1, T3; got %s, %s", result.Select.Tasks[0].ID, result.Select.Tasks[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_UpdateSetCustomField(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`update where id = "T1" set severity="high"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if result.Update == nil || len(result.Update.Updated) != 1 {
|
||||
t.Fatal("expected 1 updated task")
|
||||
}
|
||||
updated := result.Update.Updated[0]
|
||||
if updated.CustomFields["severity"] != "high" {
|
||||
t.Errorf("severity = %v, want high", updated.CustomFields["severity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_OrderByCustomEnum(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"severity": "high"}},
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "critical"}},
|
||||
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`select order by severity`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
// enum values are compared as strings: "critical" < "high" < "low"
|
||||
wantIDs := []string{"T2", "T1", "T3"}
|
||||
if len(result.Select.Tasks) != 3 {
|
||||
t.Fatalf("expected 3 tasks, got %d", len(result.Select.Tasks))
|
||||
}
|
||||
for i, wantID := range wantIDs {
|
||||
if result.Select.Tasks[i].ID != wantID {
|
||||
t.Errorf("task[%d].ID = %q, want %q", i, result.Select.Tasks[i].ID, wantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_OrderByCustomBool(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
|
||||
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
|
||||
}
|
||||
|
||||
stmt, err := p.ParseStatement(`select order by flag`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
// false < true: T2 first, then T1 and T3 (stable order)
|
||||
if len(result.Select.Tasks) != 3 {
|
||||
t.Fatalf("expected 3 tasks, got %d", len(result.Select.Tasks))
|
||||
}
|
||||
if result.Select.Tasks[0].ID != "T2" {
|
||||
t.Errorf("task[0].ID = %q, want T2 (false before true)", result.Select.Tasks[0].ID)
|
||||
}
|
||||
if result.Select.Tasks[1].ID != "T1" {
|
||||
t.Errorf("task[1].ID = %q, want T1", result.Select.Tasks[1].ID)
|
||||
}
|
||||
if result.Select.Tasks[2].ID != "T3" {
|
||||
t.Errorf("task[2].ID = %q, want T3", result.Select.Tasks[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_BoolLiteralEval(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
|
||||
}
|
||||
|
||||
// manually construct statement with BoolLiteral (as lowering produces)
|
||||
stmt := &Statement{
|
||||
Select: &SelectStmt{
|
||||
Where: &CompareExpr{
|
||||
Left: &FieldRef{Name: "flag"},
|
||||
Op: "=",
|
||||
Right: &BoolLiteral{Value: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
|
||||
t.Fatalf("expected T1, got %v", result.Select.Tasks)
|
||||
}
|
||||
|
||||
// test false literal
|
||||
stmt2 := &Statement{
|
||||
Select: &SelectStmt{
|
||||
Where: &CompareExpr{
|
||||
Left: &FieldRef{Name: "flag"},
|
||||
Op: "=",
|
||||
Right: &BoolLiteral{Value: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
result2, err := e.Execute(stmt2, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
|
||||
t.Fatalf("expected T2, got %v", result2.Select.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_BoolStringCoercion(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
|
||||
}
|
||||
|
||||
// compare bool field against string "true" — should coerce and match
|
||||
stmt := &Statement{
|
||||
Select: &SelectStmt{
|
||||
Where: &CompareExpr{
|
||||
Left: &FieldRef{Name: "flag"},
|
||||
Op: "=",
|
||||
Right: &StringLiteral{Value: "true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
|
||||
t.Errorf("bool=string 'true': got %v, want [T1]", result.Select.Tasks)
|
||||
}
|
||||
|
||||
// compare bool field against string "FALSE" — case-insensitive coercion
|
||||
stmt2 := &Statement{
|
||||
Select: &SelectStmt{
|
||||
Where: &CompareExpr{
|
||||
Left: &FieldRef{Name: "flag"},
|
||||
Op: "=",
|
||||
Right: &StringLiteral{Value: "FALSE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
result2, err := e.Execute(stmt2, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
|
||||
t.Errorf("bool=string 'FALSE': got %v, want [T2]", result2.Select.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_BoolInCaseInsensitive(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false}},
|
||||
}
|
||||
|
||||
// bool field in list of string bool-literals with mixed case
|
||||
stmt, err := p.ParseStatement(`select where flag in ["True", "FALSE"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 2 {
|
||||
t.Errorf("bool in [True, FALSE]: got %d tasks, want 2", len(result.Select.Tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_NilDoesNotMatchZero(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready"}, // flag unset, score unset
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": false, "score": 0}},
|
||||
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"flag": true, "score": 42}},
|
||||
}
|
||||
|
||||
// "flag = false" should only match T2 (explicitly false), not T1 (unset)
|
||||
stmt, err := p.ParseStatement(`select where flag = false`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T2" {
|
||||
ids := make([]string, len(result.Select.Tasks))
|
||||
for i, tk := range result.Select.Tasks {
|
||||
ids[i] = tk.ID
|
||||
}
|
||||
t.Errorf("flag=false: got %v, want [T2]", ids)
|
||||
}
|
||||
|
||||
// "score = 0" should only match T2, not T1
|
||||
stmt2, err := p.ParseStatement(`select where score = 0`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result2, err := e.Execute(stmt2, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result2.Select.Tasks) != 1 || result2.Select.Tasks[0].ID != "T2" {
|
||||
ids := make([]string, len(result2.Select.Tasks))
|
||||
for i, tk := range result2.Select.Tasks {
|
||||
ids[i] = tk.ID
|
||||
}
|
||||
t.Errorf("score=0: got %v, want [T2]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_NilMatchesIsEmpty(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready"}, // flag unset
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"flag": true}},
|
||||
}
|
||||
|
||||
// "flag is empty" should match T1 (unset → nil → isZeroValue true)
|
||||
stmt, err := p.ParseStatement(`select where flag is empty`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T1" {
|
||||
ids := make([]string, len(result.Select.Tasks))
|
||||
for i, tk := range result.Select.Tasks {
|
||||
ids[i] = tk.ID
|
||||
}
|
||||
t.Errorf("flag is empty: got %v, want [T1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_NilSortsFirst(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready", CustomFields: map[string]interface{}{"score": 10}},
|
||||
{ID: "T2", Title: "B", Status: "ready"}, // score unset → nil
|
||||
{ID: "T3", Title: "C", Status: "ready", CustomFields: map[string]interface{}{"score": 0}},
|
||||
}
|
||||
|
||||
// "order by score" — nil sorts before 0 before 10
|
||||
stmt, err := p.ParseStatement(`select order by score`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
gotIDs := make([]string, len(result.Select.Tasks))
|
||||
for i, tk := range result.Select.Tasks {
|
||||
gotIDs[i] = tk.ID
|
||||
}
|
||||
wantIDs := []string{"T2", "T3", "T1"} // nil, 0, 10
|
||||
if !reflect.DeepEqual(gotIDs, wantIDs) {
|
||||
t.Errorf("order by score: got %v, want %v", gotIDs, wantIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_NilNotInList(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "ready"}, // severity unset
|
||||
{ID: "T2", Title: "B", Status: "ready", CustomFields: map[string]interface{}{"severity": "low"}},
|
||||
}
|
||||
|
||||
// unset field should not be "in" any list
|
||||
stmt, err := p.ParseStatement(`select where severity in ["low"]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "T2" {
|
||||
ids := make([]string, len(result.Select.Tasks))
|
||||
for i, tk := range result.Select.Tasks {
|
||||
ids[i] = tk.ID
|
||||
}
|
||||
t.Errorf("severity in [low]: got %v, want [T2]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_EnumInCaseInsensitive(t *testing.T) {
|
||||
e := newCustomExecutor()
|
||||
p := newCustomParser2()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: "T1", Title: "A", Status: "done", Type: "story", CustomFields: map[string]interface{}{"severity": "low"}},
|
||||
{ID: "T2", Title: "B", Status: "ready", Type: "bug", CustomFields: map[string]interface{}{"severity": "high"}},
|
||||
{ID: "T3", Title: "C", Status: "ready", Type: "story", CustomFields: map[string]interface{}{"severity": "critical"}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "custom enum in with different case",
|
||||
query: `select where severity in ["LOW"]`,
|
||||
wantIDs: []string{"T1"},
|
||||
},
|
||||
{
|
||||
name: "custom enum not-in with different case",
|
||||
query: `select where severity not in ["LOW", "HIGH"]`,
|
||||
wantIDs: []string{"T3"},
|
||||
},
|
||||
{
|
||||
name: "custom enum in with mixed case list",
|
||||
query: `select where severity in ["High", "Critical"]`,
|
||||
wantIDs: []string{"T2", "T3"},
|
||||
},
|
||||
{
|
||||
name: "built-in status in canonical case",
|
||||
query: `select where status in ["done"]`,
|
||||
wantIDs: []string{"T1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.query)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
result, err := e.Execute(stmt, tasks)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
gotIDs := make([]string, len(result.Select.Tasks))
|
||||
for i, tk := range result.Select.Tasks {
|
||||
gotIDs[i] = tk.ID
|
||||
}
|
||||
if !reflect.DeepEqual(gotIDs, tt.wantIDs) {
|
||||
t.Errorf("got IDs %v, want %v", gotIDs, tt.wantIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue