mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
Merge pull request #47 from boolean-maybe/feature/ruki-plugin
feature/ruki plugin
This commit is contained in:
commit
0fb4baa899
73 changed files with 3319 additions and 5089 deletions
|
|
@ -1,4 +1,6 @@
|
|||
# AI skills
|
||||
# AI collaboration
|
||||
|
||||
## AI skills
|
||||
|
||||
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
|
||||
If installed you can:
|
||||
|
|
@ -7,4 +9,23 @@ If installed you can:
|
|||
- create, find, modify and delete tikis using AI
|
||||
- create tikis/dokis directly from Markdown files
|
||||
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
|
||||
- Keep a history of prompts/plans by saving prompts or plans with your repo
|
||||
- Keep a history of prompts/plans by saving prompts or plans with your repo
|
||||
|
||||
## Chat with AI
|
||||
|
||||
If you [configured](config.md) an AI agent these features will be enabled:
|
||||
|
||||
- open current tiki in an AI agent such as [Claude Code](https://code.claude.com) and have it read it. You can then chat or edit it without having to find it first
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# AI agent integration
|
||||
ai:
|
||||
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
|
||||
# Enables AI collaboration features
|
||||
# Omit or leave empty to disable
|
||||
```
|
||||
|
|
@ -62,7 +62,7 @@ Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd
|
|||
|
||||
**Views (plugins)** — merged by name across files. The user config is the base; project and cwd files override individual fields:
|
||||
- Non-empty fields in the override replace the base (description, key, colors, view mode)
|
||||
- Non-empty arrays in the override replace the entire base array (lanes, actions, sort)
|
||||
- Non-empty arrays in the override replace the entire base array (lanes, actions)
|
||||
- Empty/zero fields in the override are ignored — the base value is kept
|
||||
- Views that only exist in the override are appended
|
||||
|
||||
|
|
@ -110,6 +110,12 @@ appearance:
|
|||
# Examples: "dracula", "monokai", "catppuccin-macchiato"
|
||||
background: "#282a36" # Code block background color (hex or ANSI e.g. "236")
|
||||
border: "#6272a4" # Code block border color (hex or ANSI e.g. "244")
|
||||
|
||||
# AI agent integration
|
||||
ai:
|
||||
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
|
||||
# Enables AI collaboration features
|
||||
# Omit or leave empty to disable
|
||||
```
|
||||
|
||||
### workflow.yaml
|
||||
|
|
@ -149,18 +155,17 @@ views:
|
|||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready' and type != 'epic'
|
||||
action: status = 'ready'
|
||||
filter: select where status = "ready" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
filter: status = 'in_progress' and type != 'epic'
|
||||
action: status = '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: status = 'review' and type != 'epic'
|
||||
action: status = 'review'
|
||||
filter: select where status = "review" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
filter: status = 'done' and type != 'epic'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
filter: select where status = "done" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="done"
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
foreground: "#5fff87"
|
||||
|
|
@ -169,12 +174,11 @@ views:
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
foreground: "#f4d6a6"
|
||||
|
|
@ -183,8 +187,7 @@ views:
|
|||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: NOW - UpdatedAt < 24hours
|
||||
sort: UpdatedAt DESC
|
||||
filter: select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
foreground: "#e2e8f0"
|
||||
|
|
@ -194,19 +197,18 @@ views:
|
|||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'ready'
|
||||
action: status = 'ready'
|
||||
filter: select where type = "epic" and status = "ready" order by priority, points desc
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority = 1
|
||||
action: status = 'backlog', priority = 1
|
||||
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority > 1
|
||||
action: status = 'backlog', priority = 2
|
||||
sort: Priority, Points DESC
|
||||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
|
|
|
|||
|
|
@ -77,17 +77,16 @@ views:
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
action: update where id = id() set status="ready"
|
||||
```
|
||||
|
||||
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.
|
||||
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
|
||||
You define the name, description, caption colors, hotkey, tiki filter and sorting. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
|
||||
You define the name, description, caption colors, hotkey, and `ruki` expressions for filtering and actions. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
|
||||
|
||||
Likewise the documentation is just a plugin:
|
||||
|
||||
|
|
@ -119,28 +118,27 @@ name: Custom
|
|||
foreground: "#5fff87"
|
||||
background: "#005f00"
|
||||
key: "F4"
|
||||
sort: Priority, Title
|
||||
lanes:
|
||||
- name: Ready
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: status = 'ready'
|
||||
action: status = 'ready'
|
||||
filter: select where status = "ready" order by priority, title
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: status = 'in_progress'
|
||||
action: status = 'in_progress'
|
||||
filter: select where status = "inProgress" order by priority, title
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: status = 'review'
|
||||
action: status = 'review'
|
||||
filter: select where status = "review" order by priority, title
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
filter: select where status = "done" order by priority, title
|
||||
action: update where id = id() set status="done"
|
||||
```
|
||||
|
||||
### Lane width
|
||||
|
|
@ -168,135 +166,92 @@ that apply to the currently selected tiki via a keyboard shortcut. These shortcu
|
|||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
action: update where id = id() set status="ready"
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: assignee = CURRENT_USER
|
||||
action: update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
Each action has:
|
||||
- `key` - a single printable character used as the keyboard shortcut
|
||||
- `label` - description shown in the header
|
||||
- `action` - an action expression (same syntax as lane actions, see below)
|
||||
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
|
||||
|
||||
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.
|
||||
|
||||
### Action expression
|
||||
### ruki expressions
|
||||
|
||||
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. Here `=`
|
||||
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
|
||||
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
|
||||
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements and actions use `update` statements.
|
||||
|
||||
#### Supported Fields
|
||||
#### Filter (select)
|
||||
|
||||
- `status` - set workflow status (must be a key defined in `workflow.yaml` statuses)
|
||||
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
|
||||
- `priority` - set numeric priority (1-5)
|
||||
- `points` - set numeric points (0 or positive, up to max points)
|
||||
- `assignee` - set assignee string
|
||||
- `tags` - add/remove tags (list)
|
||||
- `dependsOn` - add/remove dependency tiki IDs (list)
|
||||
- `due` - set due date (YYYY-MM-DD format)
|
||||
- `recurrence` - set recurrence pattern (cron format: `0 0 * * *`, `0 0 * * MON`, `0 0 1 * *`)
|
||||
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
|
||||
|
||||
#### Operators
|
||||
```sql
|
||||
-- basic filter with sort
|
||||
select where status = "backlog" and type != "epic" order by priority, id
|
||||
|
||||
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`, `due`, `recurrence`
|
||||
- `+=` adds tags or dependencies, `-=` removes them
|
||||
- multiple operations are separated by commas: `status=done, tags+=[moved]`
|
||||
-- recent items, most recent first
|
||||
select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
|
||||
#### Literals
|
||||
-- multiple conditions
|
||||
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
|
||||
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
|
||||
- use quotes when the value has spaces
|
||||
- integers are used for `priority` and `points`
|
||||
- tag lists use brackets: `tags += [ui, frontend]`
|
||||
- `CURRENT_USER` assigns the current git user to `assignee`
|
||||
- example: `assignee = CURRENT_USER`
|
||||
|
||||
### Filter expression
|
||||
|
||||
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
|
||||
|
||||
#### Supported Fields
|
||||
|
||||
You can filter on these task fields:
|
||||
- `id` - Task identifier (e.g., 'TIKI-m7n2xk')
|
||||
- `title` - Task title text (case-insensitive)
|
||||
- `type` - Task type: 'story', 'bug', 'spike', or 'epic' (case-insensitive)
|
||||
- `status` - Workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - Assigned user (case-insensitive)
|
||||
- `priority` - Numeric priority value
|
||||
- `points` - Story points estimate
|
||||
- `tags` (or `tag`) - List of tags (case-insensitive)
|
||||
- `dependsOn` - List of dependency tiki IDs
|
||||
- `due` - Due date (YYYY-MM-DD format, supports time arithmetic)
|
||||
- `recurrence` - Recurrence pattern (cron string, compared as string)
|
||||
- `createdAt` - Creation timestamp
|
||||
- `updatedAt` - Last update timestamp
|
||||
|
||||
All string comparisons are case-insensitive.
|
||||
|
||||
#### Operators
|
||||
|
||||
- **Comparison**: `=` (or `==`), `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `AND`, `OR`, `NOT` (precedence: NOT > AND > OR)
|
||||
- **Membership**: `IN`, `NOT IN` (check if value in list using `[val1, val2]`)
|
||||
- **Grouping**: Use parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and Special Values
|
||||
|
||||
**Special expressions**:
|
||||
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
|
||||
- `NOW` - Current timestamp
|
||||
|
||||
**Time expressions**:
|
||||
- `NOW - UpdatedAt` - Time elapsed since update
|
||||
- `NOW - CreatedAt` - Time since creation
|
||||
- Duration units: `min`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`, `month`/`months`
|
||||
- Examples: `2hours`, `14days`, `3weeks`, `60min`, `1month`
|
||||
- Operators: `+` (add), `-` (subtract or compute duration)
|
||||
|
||||
**Special tag semantics**:
|
||||
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
|
||||
- This allows intersection testing across tag arrays
|
||||
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Multiple statuses
|
||||
status = 'ready' OR status = 'in_progress'
|
||||
|
||||
# With tags
|
||||
tags IN ['frontend', 'urgent']
|
||||
|
||||
# High priority bugs
|
||||
type = 'bug' AND priority = 0
|
||||
|
||||
# Features and ideas assigned to me
|
||||
(type = 'feature' OR tags IN ['idea']) AND assignee = CURRENT_USER
|
||||
|
||||
# Unassigned large tasks
|
||||
assignee = '' AND points >= 5
|
||||
|
||||
# Recently created tasks not in backlog
|
||||
(NOW - CreatedAt < 2hours) AND status != 'backlog'
|
||||
-- assigned to me
|
||||
select where assignee = user() order by priority
|
||||
```
|
||||
|
||||
### Sorting
|
||||
#### Action (update)
|
||||
|
||||
The `sort` field determines the order in which tikis appear in the view. You can sort by one or more fields, and control the direction (ascending or descending).
|
||||
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
|
||||
|
||||
#### Sort Syntax
|
||||
```sql
|
||||
-- set status on move
|
||||
update where id = id() set status="ready"
|
||||
|
||||
```text
|
||||
sort: Field1, Field2 DESC, Field3
|
||||
-- set multiple fields
|
||||
update where id = id() set status="backlog" priority=2
|
||||
|
||||
-- assign to current user
|
||||
update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
#### Examples
|
||||
#### Supported fields
|
||||
|
||||
```text
|
||||
# Sort by creation time descending (recent first), then priority, then title
|
||||
sort: CreatedAt DESC, Priority, Title
|
||||
```
|
||||
- `id` - task identifier (e.g., "TIKI-M7N2XK")
|
||||
- `title` - task title text
|
||||
- `type` - task type: "story", "bug", "spike", or "epic"
|
||||
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - assigned user
|
||||
- `priority` - numeric priority value (1-5)
|
||||
- `points` - story points estimate
|
||||
- `tags` - list of tags
|
||||
- `dependsOn` - list of dependency tiki IDs
|
||||
- `due` - due date (YYYY-MM-DD format)
|
||||
- `recurrence` - recurrence pattern (cron format)
|
||||
- `createdAt` - creation timestamp
|
||||
- `updatedAt` - last update timestamp
|
||||
|
||||
#### Conditions
|
||||
|
||||
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
|
||||
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
|
||||
- **Emptiness**: `assignee is empty`, `tags is not empty`
|
||||
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
|
||||
- **Grouping**: parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and built-ins
|
||||
|
||||
- Strings: double-quoted (`"ready"`, `"alex"`)
|
||||
- Integers: `1`, `5`
|
||||
- Dates: `2026-03-25`
|
||||
- Durations: `2hour`, `14day`, `3week`, `1month`
|
||||
- Lists: `["bug", "frontend"]`
|
||||
- `user()` — current user
|
||||
- `now()` — current timestamp
|
||||
- `id()` — currently selected tiki (in plugin context)
|
||||
- `count(select where ...)` — count matching tikis
|
||||
|
||||
For the full language reference, see the [ruki documentation](ruki/index.md).
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
- [Markdown viewer](markdown-viewer.md)
|
||||
- [Image support](image-requirements.md)
|
||||
- [Customization](customization.md)
|
||||
- [Ruki](ruki/index.md)
|
||||
- [ruki](ruki/index.md)
|
||||
- [tiki format](tiki-format.md)
|
||||
- [Quick capture](quick-capture.md)
|
||||
- [AI skills](skills.md)
|
||||
- [AI collaboration](ai.md)
|
||||
- [Recipes](ideas/triggers.md)
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
# Ruki Documentation
|
||||
# ruki Documentation
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Ruki](#ruki)
|
||||
- [ruki](#ruki)
|
||||
- [Quick start](#quick-start)
|
||||
- [More details](#more-details)
|
||||
|
||||
## Ruki
|
||||
## ruki
|
||||
|
||||
This section documents the Ruki language. Ruki is a small language for finding, creating, updating, and deleting tikis, with SQL-like statements and trigger rules.
|
||||
This section documents the `ruki` language. `ruki` is a small language for finding, creating, updating, and deleting tikis, with SQL-like statements and trigger rules.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
This page describes the operators and built-in functions available in Ruki.
|
||||
This page describes the operators and built-in functions available in `ruki`.
|
||||
|
||||
## Operator precedence and associativity
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ create title="x" dependsOn=dependsOn + tags
|
|||
|
||||
## Built-in functions
|
||||
|
||||
Ruki has these built-ins:
|
||||
`ruki` has these built-ins:
|
||||
|
||||
| Name | Result type | Arguments | Notes |
|
||||
|---|---|---|---|
|
||||
|
|
@ -246,7 +246,7 @@ after update where new.status = "in progress" run("echo hello")
|
|||
|
||||
## Shell-related forms
|
||||
|
||||
Ruki includes two shell-related forms:
|
||||
`ruki` includes two shell-related forms:
|
||||
|
||||
- `call(...)` as a string-returning expression
|
||||
- `run(...)` as an `after`-trigger action
|
||||
|
|
|
|||
|
|
@ -11,18 +11,18 @@
|
|||
|
||||
## Overview
|
||||
|
||||
This page is a practical introduction to the Ruki language. It covers the main statement forms, the conditions they use, and the trigger rules that let you block or react to changes.
|
||||
This page is a practical introduction to the `ruki` language. It covers the main statement forms, the conditions they use, and the trigger rules that let you block or react to changes.
|
||||
|
||||
## Mental model
|
||||
|
||||
Ruki has two top-level forms:
|
||||
`ruki` has two top-level forms:
|
||||
|
||||
- Statements: `select`, `create`, `update`, and `delete`
|
||||
- Triggers: `before` or `after` rules attached to `create`, `update`, or `delete`
|
||||
|
||||
Statements read and change tiki fields such as `status`, `type`, `tags`, `dependsOn`, `priority`, and `due`. Triggers use the same fields and conditions, but add `before` or `after` timing around `create`, `update`, or `delete`.
|
||||
|
||||
The simplest way to read Ruki is:
|
||||
The simplest way to read `ruki` is:
|
||||
|
||||
- `select` filters tikis
|
||||
- `create` assigns fields for a new tiki
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
This page explains how Ruki statements, triggers, conditions, and expressions behave.
|
||||
This page explains how `ruki` statements, triggers, conditions, and expressions behave.
|
||||
|
||||
## Statement semantics
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
|
||||
## Overview
|
||||
|
||||
This page describes Ruki syntax. It starts with tokens and then shows the grammar for statements, triggers, conditions, and expressions.
|
||||
This page describes `ruki` syntax. It starts with tokens and then shows the grammar for statements, triggers, conditions, and expressions.
|
||||
|
||||
## Lexical structure
|
||||
|
||||
Ruki uses these token classes:
|
||||
`ruki` uses these token classes:
|
||||
|
||||
- comments: `--` to end of line
|
||||
- whitespace: ignored between tokens
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ after delete
|
|||
|
||||
Triggers are defined in `workflow.yaml` under the `triggers:` key. Each entry has two fields:
|
||||
|
||||
- `ruki` — the trigger rule in Ruki syntax (required)
|
||||
- `ruki` — the trigger rule in `ruki` syntax (required)
|
||||
- `description` — an optional label
|
||||
|
||||
```yaml
|
||||
|
|
@ -309,7 +309,7 @@ Time triggers are parsed and validated at startup alongside event triggers. A pa
|
|||
|
||||
Triggers are loaded during application startup, after the store is initialized but before controllers are created.
|
||||
|
||||
- Each trigger definition is parsed with the ruki parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
|
||||
- Each trigger definition is parsed with the `ruki` parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
|
||||
- If no `triggers:` section is found in any workflow file, zero triggers are loaded and the app starts normally.
|
||||
- Successfully loaded triggers are logged with a count at startup.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@
|
|||
|
||||
## Overview
|
||||
|
||||
This page explains the value types used in Ruki. You do not write types explicitly. Ruki works them out from the values, expressions, built-in functions, and tiki fields you use.
|
||||
This page explains the value types used in `ruki`. You do not write types explicitly. `ruki` works them out from the values, expressions, built-in functions, and tiki fields you use.
|
||||
|
||||
## Value types
|
||||
|
||||
Ruki uses these value types:
|
||||
`ruki` uses these value types:
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
|
|
@ -37,7 +37,7 @@ Ruki uses these value types:
|
|||
|
||||
## Field catalog
|
||||
|
||||
The workflow field catalog exposes these fields to Ruki:
|
||||
The workflow field catalog exposes these fields to `ruki`:
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@
|
|||
|
||||
## Overview
|
||||
|
||||
This page explains the errors you can get in Ruki. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
|
||||
This page explains the errors you can get in `ruki`. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
|
||||
|
||||
## Validation layers
|
||||
|
||||

|
||||
|
||||
Ruki has two distinct failure stages:
|
||||
`ruki` has two distinct failure stages:
|
||||
|
||||
1. Parse-time failures
|
||||
2. Validation-time failures
|
||||
|
|
|
|||
|
|
@ -194,11 +194,11 @@ When asked to remove or clear a due date:
|
|||
|
||||
### Query by due date
|
||||
|
||||
Users can filter tasks by due date in the TUI using filter expressions:
|
||||
- `due = '2026-04-01'` - exact match
|
||||
- `due < '2026-04-01'` - before date
|
||||
- `due < NOW` - overdue tasks
|
||||
- `due - NOW < 7day` - due within 7 days
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ func DefaultTheme() Theme {
|
|||
LabelColor: colors.BurndownChartLabelColor,
|
||||
ValueColor: colors.BurndownChartValueColor,
|
||||
BarColor: colors.BurndownChartBarColor,
|
||||
BackgroundColor: config.GetContentBackgroundColor(),
|
||||
BackgroundColor: config.GetColors().ContentBackgroundColor,
|
||||
BarGradientFrom: colors.BurndownChartGradientFrom.Start,
|
||||
BarGradientTo: colors.BurndownChartGradientTo.Start,
|
||||
DotChar: '⣿', // braille full cell for dense dot matrix
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func barFillColor(bar Bar, row, total int, theme Theme) tcell.Color {
|
|||
|
||||
// Use adaptive gradient: solid color when gradients disabled
|
||||
if !config.UseGradients {
|
||||
return config.FallbackBurndownColor
|
||||
return config.GetColors().FallbackBurndownColor
|
||||
}
|
||||
|
||||
t := float64(row) / float64(total-1)
|
||||
|
|
|
|||
|
|
@ -25,10 +25,9 @@ func NewCompletionPrompt(words []string) *CompletionPrompt {
|
|||
inputField := tview.NewInputField()
|
||||
|
||||
// Configure the input field
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor)
|
||||
cp := &CompletionPrompt{
|
||||
InputField: inputField,
|
||||
words: words,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ type DateEdit struct {
|
|||
// NewDateEdit creates a new date input field.
|
||||
func NewDateEdit() *DateEdit {
|
||||
inputField := tview.NewInputField()
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor)
|
||||
|
||||
de := &DateEdit{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ func NewEditSelectList(values []string, allowTyping bool) *EditSelectList {
|
|||
inputField := tview.NewInputField()
|
||||
|
||||
// Configure the input field
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor)
|
||||
|
||||
esl := &EditSelectList{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -37,8 +37,9 @@ func NewIntEditSelect(min, max int, allowTyping bool) *IntEditSelect {
|
|||
}
|
||||
|
||||
inputField := tview.NewInputField()
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor)
|
||||
|
||||
ies := &IntEditSelect{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ type RecurrenceEdit struct {
|
|||
// NewRecurrenceEdit creates a new recurrence editor.
|
||||
func NewRecurrenceEdit() *RecurrenceEdit {
|
||||
inputField := tview.NewInputField()
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor)
|
||||
|
||||
re := &RecurrenceEdit{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func NewTaskList(maxVisibleRows int) *TaskList {
|
|||
Box: tview.NewBox(),
|
||||
maxVisibleRows: maxVisibleRows,
|
||||
idGradient: colors.TaskBoxIDColor,
|
||||
idFallback: config.FallbackTaskIDColor,
|
||||
idFallback: colors.FallbackTaskIDColor,
|
||||
titleColor: colors.TaskBoxTitleColor,
|
||||
selectionColor: colors.TaskListSelectionColor,
|
||||
statusDoneColor: colors.TaskListStatusDoneColor,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ func TestNewTaskList(t *testing.T) {
|
|||
if tl.idGradient != colors.TaskBoxIDColor {
|
||||
t.Error("Expected ID gradient from config")
|
||||
}
|
||||
if tl.idFallback != config.FallbackTaskIDColor {
|
||||
if tl.idFallback != config.GetColors().FallbackTaskIDColor {
|
||||
t.Error("Expected ID fallback from config")
|
||||
}
|
||||
if tl.titleColor != colors.TaskBoxTitleColor {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func (w *WordList) Draw(screen tcell.Screen) {
|
|||
}
|
||||
|
||||
wordStyle := tcell.StyleDefault.Foreground(w.fgColor).Background(w.bgColor)
|
||||
spaceStyle := tcell.StyleDefault.Background(config.GetContentBackgroundColor())
|
||||
spaceStyle := tcell.StyleDefault.Background(config.GetColors().ContentBackgroundColor)
|
||||
|
||||
currentX := x
|
||||
currentY := y
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ type ColorConfig struct {
|
|||
TaskDetailEditFocusText string // tview color string like "[white]"
|
||||
TaskDetailTagForeground tcell.Color
|
||||
TaskDetailTagBackground tcell.Color
|
||||
TaskDetailPlaceholderColor tcell.Color
|
||||
|
||||
// Content area colors (base canvas for editable/readable content)
|
||||
ContentBackgroundColor tcell.Color
|
||||
ContentTextColor tcell.Color
|
||||
|
||||
// Search box colors
|
||||
SearchBoxLabelColor tcell.Color
|
||||
|
|
@ -87,6 +92,13 @@ type ColorConfig struct {
|
|||
HeaderActionViewKeyColor string // tview color string for view action keys
|
||||
HeaderActionViewLabelColor string // tview color string for view action labels
|
||||
|
||||
// Plugin-specific colors
|
||||
DepsEditorBackground tcell.Color // muted slate for dependency editor caption
|
||||
|
||||
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
|
||||
FallbackTaskIDColor tcell.Color // Deep Sky Blue (end of task ID gradient)
|
||||
FallbackBurndownColor tcell.Color // Purple (start of burndown gradient)
|
||||
|
||||
// Statusline colors (bottom bar, powerline style)
|
||||
StatuslineBg string // hex color for stat segment background, e.g. "#3a3a5c"
|
||||
StatuslineFg string // hex color for stat segment text, e.g. "#cccccc"
|
||||
|
|
@ -142,6 +154,11 @@ func DefaultColors() *ColorConfig {
|
|||
TaskDetailEditFocusText: "[white]", // White text after arrow
|
||||
TaskDetailTagForeground: tcell.NewRGBColor(180, 200, 220), // Light blue-gray text
|
||||
TaskDetailTagBackground: tcell.NewRGBColor(30, 50, 120), // Dark blue background (more bluish)
|
||||
TaskDetailPlaceholderColor: tcell.ColorGray, // Gray for placeholder text in edit fields
|
||||
|
||||
// Content area (base canvas)
|
||||
ContentBackgroundColor: tcell.ColorBlack, // dark theme: explicit black
|
||||
ContentTextColor: tcell.ColorWhite, // dark theme: white text
|
||||
|
||||
// Search box
|
||||
SearchBoxLabelColor: tcell.ColorWhite,
|
||||
|
|
@ -196,6 +213,13 @@ func DefaultColors() *ColorConfig {
|
|||
HeaderActionViewKeyColor: "#5fafff", // cyan for view-specific actions
|
||||
HeaderActionViewLabelColor: "#808080", // gray for view-specific labels
|
||||
|
||||
// Plugin-specific
|
||||
DepsEditorBackground: tcell.NewHexColor(0x4e5768), // Muted slate
|
||||
|
||||
// Fallback solid colors (no-gradient terminals)
|
||||
FallbackTaskIDColor: tcell.NewRGBColor(0, 191, 255), // Deep Sky Blue
|
||||
FallbackBurndownColor: tcell.NewRGBColor(134, 90, 214), // Purple
|
||||
|
||||
// Statusline (Nord theme)
|
||||
StatuslineBg: "#434c5e", // Nord polar night 3
|
||||
StatuslineFg: "#d8dee9", // Nord snow storm 1
|
||||
|
|
@ -221,30 +245,14 @@ var UseGradients bool
|
|||
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
|
||||
var UseWideGradients bool
|
||||
|
||||
// Plugin-specific background colors for code-only plugins
|
||||
var (
|
||||
// DepsEditorBackground: muted slate for the dependency editor caption
|
||||
DepsEditorBackground = tcell.NewHexColor(0x4e5768)
|
||||
)
|
||||
|
||||
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
|
||||
var (
|
||||
// Caption title fallback: Royal Blue (end of gradient)
|
||||
FallbackTitleColor = tcell.NewRGBColor(65, 105, 225)
|
||||
// Task ID fallback: Deep Sky Blue (end of gradient)
|
||||
FallbackTaskIDColor = tcell.NewRGBColor(0, 191, 255)
|
||||
// Burndown chart fallback: Purple (start of gradient)
|
||||
FallbackBurndownColor = tcell.NewRGBColor(134, 90, 214)
|
||||
// Caption row fallback: Midpoint of Midnight Blue to Royal Blue
|
||||
FallbackCaptionColor = tcell.NewRGBColor(45, 65, 169)
|
||||
)
|
||||
|
||||
// GetColors returns the global color configuration with theme-aware overrides
|
||||
func GetColors() *ColorConfig {
|
||||
if !colorsInitialized {
|
||||
globalColors = DefaultColors()
|
||||
// Apply theme-aware overrides for critical text colors
|
||||
if GetEffectiveTheme() == "light" {
|
||||
globalColors.ContentBackgroundColor = tcell.ColorDefault
|
||||
globalColors.ContentTextColor = tcell.ColorBlack
|
||||
globalColors.SearchBoxLabelColor = tcell.ColorBlack
|
||||
globalColors.SearchBoxTextColor = tcell.ColorBlack
|
||||
globalColors.InputFieldTextColor = tcell.ColorBlack
|
||||
|
|
|
|||
|
|
@ -29,18 +29,17 @@ views:
|
|||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready' and type != 'epic'
|
||||
action: status = 'ready'
|
||||
filter: select where status = "ready" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
filter: status = 'inProgress' and type != 'epic'
|
||||
action: status = 'inProgress'
|
||||
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
filter: status = 'review' and type != 'epic'
|
||||
action: status = 'review'
|
||||
filter: select where status = "review" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
filter: status = 'done' and type != 'epic'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
filter: select where status = "done" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="done"
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
foreground: "#5fff87"
|
||||
|
|
@ -49,12 +48,11 @@ views:
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
foreground: "#f4d6a6"
|
||||
|
|
@ -63,8 +61,7 @@ views:
|
|||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: NOW - UpdatedAt < 24hours
|
||||
sort: UpdatedAt DESC
|
||||
filter: select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
foreground: "#e2e8f0"
|
||||
|
|
@ -74,19 +71,18 @@ views:
|
|||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'ready'
|
||||
action: status = 'ready'
|
||||
filter: select where type = "epic" and status = "ready" order by priority, points desc
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority = 1
|
||||
action: status = 'backlog', priority = 1
|
||||
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority > 1
|
||||
action: status = 'backlog', priority = 2
|
||||
sort: Priority, Points DESC
|
||||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -375,24 +374,6 @@ func GetEffectiveTheme() string {
|
|||
return "dark" // default fallback
|
||||
}
|
||||
|
||||
// GetContentBackgroundColor returns the background color for markdown content areas
|
||||
// Dark theme needs black background for light text; light theme uses terminal default
|
||||
func GetContentBackgroundColor() tcell.Color {
|
||||
if GetEffectiveTheme() == "dark" {
|
||||
return tcell.ColorBlack
|
||||
}
|
||||
return tcell.ColorDefault
|
||||
}
|
||||
|
||||
// GetContentTextColor returns the appropriate text color for content areas
|
||||
// Dark theme uses white text; light theme uses black text
|
||||
func GetContentTextColor() tcell.Color {
|
||||
if GetEffectiveTheme() == "dark" {
|
||||
return tcell.ColorWhite
|
||||
}
|
||||
return tcell.ColorBlack
|
||||
}
|
||||
|
||||
// GetGradientThreshold returns the minimum color count required for gradients
|
||||
// Valid values: 16, 256, 16777216 (truecolor)
|
||||
func GetGradientThreshold() int {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ package controller
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -34,6 +36,7 @@ func NewDepsController(
|
|||
pluginDef *plugin.TikiPlugin,
|
||||
navController *NavigationController,
|
||||
statusline *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *DepsController {
|
||||
return &DepsController{
|
||||
pluginBase: pluginBase{
|
||||
|
|
@ -44,6 +47,7 @@ func NewDepsController(
|
|||
navController: navController,
|
||||
statusline: statusline,
|
||||
registry: DepsViewActions(),
|
||||
schema: schema,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -164,39 +168,42 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
|
||||
contextTaskID := dc.pluginDef.TaskID
|
||||
|
||||
// determine which tasks to update and how
|
||||
type update struct {
|
||||
taskID string
|
||||
action plugin.LaneAction
|
||||
}
|
||||
var updates []update
|
||||
|
||||
// build a ruki UPDATE query for the dependency change
|
||||
var query string
|
||||
switch {
|
||||
case sourceLane == depsLaneAll && targetLane == depsLaneBlocks:
|
||||
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorAdd, contextTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn+["%s"]`, movedTaskID, contextTaskID)
|
||||
case sourceLane == depsLaneAll && targetLane == depsLaneDepends:
|
||||
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorAdd, movedTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn+["%s"]`, contextTaskID, movedTaskID)
|
||||
case sourceLane == depsLaneBlocks && targetLane == depsLaneAll:
|
||||
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorRemove, contextTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn-["%s"]`, movedTaskID, contextTaskID)
|
||||
case sourceLane == depsLaneDepends && targetLane == depsLaneAll:
|
||||
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorRemove, movedTaskID)})
|
||||
query = fmt.Sprintf(`update where id = "%s" set dependsOn=dependsOn-["%s"]`, contextTaskID, movedTaskID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for _, u := range updates {
|
||||
taskItem := dc.taskStore.GetTask(u.taskID)
|
||||
if taskItem == nil {
|
||||
slog.Error("deps move: task not found", "task_id", u.taskID)
|
||||
return false
|
||||
}
|
||||
updated, err := plugin.ApplyLaneAction(taskItem, u.action, "")
|
||||
if err != nil {
|
||||
slog.Error("deps move: failed to apply action", "task_id", u.taskID, "error", err)
|
||||
return false
|
||||
}
|
||||
parser := ruki.NewParser(dc.schema)
|
||||
stmt, err := parser.ParseAndValidateStatement(query, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
slog.Error("deps move: failed to parse ruki query", "query", query, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
executor := dc.newExecutor()
|
||||
result, err := executor.Execute(stmt, dc.taskStore.GetAllTasks())
|
||||
if err != nil {
|
||||
slog.Error("deps move: failed to execute ruki query", "query", query, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if result.Update == nil || len(result.Update.Updated) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, updated := range result.Update.Updated {
|
||||
if err := dc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("deps move: failed to update task", "task_id", u.taskID, "error", err)
|
||||
slog.Error("deps move: failed to update task", "task_id", updated.ID, "error", err)
|
||||
if dc.statusline != nil {
|
||||
dc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
|
|
@ -208,17 +215,6 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// depsAction builds a LaneAction that adds or removes a single task ID from dependsOn.
|
||||
func depsAction(op plugin.ActionOperator, taskID string) plugin.LaneAction {
|
||||
return plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{{
|
||||
Field: plugin.ActionFieldDependsOn,
|
||||
Operator: op,
|
||||
DependsOn: []string{taskID},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDependsTasks looks up full task objects for the context task's DependsOn IDs.
|
||||
func (dc *DepsController) resolveDependsTasks(contextTask *task.Task, allTasks []*task.Task) []*task.Task {
|
||||
if len(contextTask.DependsOn) == 0 {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"slices"
|
||||
"testing"
|
||||
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
|
|
@ -52,7 +53,7 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
|
|||
gate.SetStore(taskStore)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil, rukiRuntime.NewSchema())
|
||||
return dc, taskStore
|
||||
}
|
||||
|
||||
|
|
@ -467,7 +468,7 @@ func TestDepsController_DeleteTask_GateError(t *testing.T) {
|
|||
|
||||
nav := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline, rukiRuntime.NewSchema())
|
||||
|
||||
// select free task in All lane
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
|
|
@ -512,7 +513,7 @@ func TestDepsController_MoveTask_UpdateError(t *testing.T) {
|
|||
|
||||
nav := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline, rukiRuntime.NewSchema())
|
||||
|
||||
// select free task in All lane, move left → Blocks
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
|
|
@ -626,7 +627,7 @@ func newDepsNavEnv(t *testing.T, blockers int, allTasks int, depends int, laneCo
|
|||
gate.SetStore(taskStore)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
|
||||
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil, rukiRuntime.NewSchema())
|
||||
}
|
||||
|
||||
func TestDepsController_NavRightAdjacentNonEmptyPreservesRow(t *testing.T) {
|
||||
|
|
@ -723,3 +724,65 @@ func TestDepsController_SuccessfulSwitchPersistsClampedTargetScroll(t *testing.T
|
|||
t.Fatalf("expected clamped scroll offset 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_ShowNavigation(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
if dc.ShowNavigation() {
|
||||
t.Error("DepsController.ShowNavigation() should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_GetFilteredTasksForLane_WithSearch(t *testing.T) {
|
||||
dc, taskStore := newDepsTestEnv(t)
|
||||
|
||||
// set search results to only include the free task
|
||||
free := taskStore.GetTask(testFreeID)
|
||||
dc.pluginConfig.SetSearchResults([]task.SearchResult{{Task: free, Score: 1.0}}, "Free")
|
||||
|
||||
// All lane should now only show the free task (matching search)
|
||||
allTasks := dc.GetFilteredTasksForLane(depsLaneAll)
|
||||
if len(allTasks) != 1 {
|
||||
t.Fatalf("expected 1 task with search narrowing, got %d", len(allTasks))
|
||||
}
|
||||
if allTasks[0].ID != testFreeID {
|
||||
t.Errorf("expected %s, got %s", testFreeID, allTasks[0].ID)
|
||||
}
|
||||
|
||||
// Blocks lane should be empty (no matching search results)
|
||||
blocksTasks := dc.GetFilteredTasksForLane(depsLaneBlocks)
|
||||
if len(blocksTasks) != 0 {
|
||||
t.Errorf("expected 0 blocks tasks with search narrowing, got %d", len(blocksTasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_GetFilteredTasksForLane_MissingContextTask(t *testing.T) {
|
||||
dc, taskStore := newDepsTestEnv(t)
|
||||
|
||||
// delete the context task
|
||||
taskStore.DeleteTask(testCtxID)
|
||||
|
||||
// all lanes should return nil when context task is missing
|
||||
if dc.GetFilteredTasksForLane(depsLaneAll) != nil {
|
||||
t.Error("expected nil when context task is missing")
|
||||
}
|
||||
if dc.GetFilteredTasksForLane(depsLaneBlocks) != nil {
|
||||
t.Error("expected nil when context task is missing")
|
||||
}
|
||||
if dc.GetFilteredTasksForLane(depsLaneDepends) != nil {
|
||||
t.Error("expected nil when context task is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_MoveTask_EmptySelection(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
|
||||
// set selected lane to blocks but with an index beyond the task list
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 99) // beyond available tasks
|
||||
|
||||
// getSelectedTaskID should return "" for an index beyond the task list,
|
||||
// so handleMoveTask should return false
|
||||
if dc.handleMoveTask(1) {
|
||||
t.Error("expected false when no task is selected (index out of range)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -51,6 +52,7 @@ type InputRouter struct {
|
|||
taskStore store.Store
|
||||
mutationGate *service.TaskMutationGate
|
||||
statusline *model.StatuslineConfig
|
||||
schema ruki.Schema
|
||||
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ func NewInputRouter(
|
|||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
statusline *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *InputRouter {
|
||||
return &InputRouter{
|
||||
navController: navController,
|
||||
|
|
@ -72,6 +75,7 @@ func NewInputRouter(
|
|||
taskStore: taskStore,
|
||||
mutationGate: mutationGate,
|
||||
statusline: statusline,
|
||||
schema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +232,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
|
|||
Description: model.DepsEditorViewDesc,
|
||||
ConfigIndex: -1,
|
||||
Type: "tiki",
|
||||
Background: config.DepsEditorBackground,
|
||||
Background: config.GetColors().DepsEditorBackground,
|
||||
},
|
||||
TaskID: taskID,
|
||||
Lanes: []plugin.TikiLane{
|
||||
|
|
@ -244,7 +248,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
|
|||
pluginConfig.SetViewMode(vm)
|
||||
}
|
||||
|
||||
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline)
|
||||
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline, ir.schema)
|
||||
|
||||
if ir.registerPlugin != nil {
|
||||
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import (
|
|||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -28,6 +28,7 @@ func NewPluginController(
|
|||
pluginDef *plugin.TikiPlugin,
|
||||
navController *NavigationController,
|
||||
statusline *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *PluginController {
|
||||
pc := &PluginController{
|
||||
pluginBase: pluginBase{
|
||||
|
|
@ -38,6 +39,7 @@ func NewPluginController(
|
|||
navController: navController,
|
||||
statusline: statusline,
|
||||
registry: PluginViewActions(),
|
||||
schema: schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -151,32 +153,67 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
executor := pc.newExecutor()
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
|
||||
input := ruki.ExecutionInput{}
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
taskItem := pc.taskStore.GetTask(taskID)
|
||||
if taskItem == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
updated, err := plugin.ApplyLaneAction(taskItem, pa.Action, currentUser)
|
||||
if err != nil {
|
||||
slog.Error("failed to apply plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("failed to update task after plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
if pa.Action.IsUpdate() || pa.Action.IsDelete() {
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
input.SelectedTaskID = taskID
|
||||
}
|
||||
|
||||
if pa.Action.IsCreate() {
|
||||
template, err := pc.taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
|
||||
return false
|
||||
}
|
||||
input.CreateTemplate = template
|
||||
}
|
||||
|
||||
result, err := executor.Execute(pa.Action, allTasks, input)
|
||||
if err != nil {
|
||||
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
pc.ensureSearchResultIncludesTask(updated)
|
||||
ctx := context.Background()
|
||||
switch {
|
||||
case result.Update != nil:
|
||||
for _, updated := range result.Update.Updated {
|
||||
if err := pc.mutationGate.UpdateTask(ctx, updated); err != nil {
|
||||
slog.Error("failed to update task after plugin action", "task_id", updated.ID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
pc.ensureSearchResultIncludesTask(updated)
|
||||
}
|
||||
case result.Create != nil:
|
||||
if err := pc.mutationGate.CreateTask(ctx, result.Create.Task); err != nil {
|
||||
slog.Error("failed to create task from plugin action", "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
case result.Delete != nil:
|
||||
for _, deleted := range result.Delete.Deleted {
|
||||
if err := pc.mutationGate.DeleteTask(ctx, deleted); err != nil {
|
||||
slog.Error("failed to delete task from plugin action", "task_id", deleted.ID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
|
||||
return true
|
||||
}
|
||||
|
|
@ -197,18 +234,24 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
taskItem := pc.taskStore.GetTask(taskID)
|
||||
if taskItem == nil {
|
||||
actionStmt := pc.pluginDef.Lanes[targetLane].Action
|
||||
if actionStmt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
updated, err := plugin.ApplyLaneAction(taskItem, pc.pluginDef.Lanes[targetLane].Action, currentUser)
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
executor := pc.newExecutor()
|
||||
result, err := executor.Execute(actionStmt, allTasks, ruki.ExecutionInput{SelectedTaskID: taskID})
|
||||
if err != nil {
|
||||
slog.Error("failed to apply lane action", "task_id", taskID, "error", err)
|
||||
slog.Error("failed to execute lane action", "task_id", taskID, "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if result.Update == nil || len(result.Update.Updated) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := result.Update.Updated[0]
|
||||
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
|
||||
if pc.statusline != nil {
|
||||
|
|
@ -231,22 +274,24 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
return nil
|
||||
}
|
||||
|
||||
// check if search is active
|
||||
searchResults := pc.pluginConfig.GetSearchResults()
|
||||
|
||||
filterStmt := pc.pluginDef.Lanes[lane].Filter
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
now := time.Now()
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
|
||||
var filtered []*task.Task
|
||||
for _, task := range allTasks {
|
||||
laneFilter := pc.pluginDef.Lanes[lane].Filter
|
||||
if laneFilter == nil || laneFilter.Evaluate(task, now, currentUser) {
|
||||
filtered = append(filtered, task)
|
||||
if filterStmt == nil {
|
||||
filtered = allTasks
|
||||
} else {
|
||||
executor := pc.newExecutor()
|
||||
result, err := executor.Execute(filterStmt, allTasks)
|
||||
if err != nil {
|
||||
slog.Error("failed to execute lane filter", "lane", lane, "error", err)
|
||||
return nil
|
||||
}
|
||||
filtered = result.Select.Tasks
|
||||
}
|
||||
|
||||
if searchResults != nil {
|
||||
// narrow by search results if active
|
||||
if searchResults := pc.pluginConfig.GetSearchResults(); searchResults != nil {
|
||||
searchTaskMap := make(map[string]bool, len(searchResults))
|
||||
for _, result := range searchResults {
|
||||
searchTaskMap[result.Task.ID] = true
|
||||
|
|
@ -254,10 +299,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
filtered = filterTasksBySearch(filtered, searchTaskMap)
|
||||
}
|
||||
|
||||
if len(pc.pluginDef.Sort) > 0 {
|
||||
plugin.SortTasks(filtered, pc.pluginDef.Sort)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
|
@ -22,6 +23,14 @@ type pluginBase struct {
|
|||
navController *NavigationController
|
||||
statusline *model.StatuslineConfig
|
||||
registry *ActionRegistry
|
||||
schema ruki.Schema
|
||||
}
|
||||
|
||||
// newExecutor creates a ruki executor configured for plugin runtime.
|
||||
func (pb *pluginBase) newExecutor() *ruki.Executor {
|
||||
userName := getCurrentUserName(pb.taskStore)
|
||||
return ruki.NewExecutor(pb.schema, func() string { return userName },
|
||||
ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimePlugin})
|
||||
}
|
||||
|
||||
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
|
||||
|
|
|
|||
|
|
@ -4,14 +4,28 @@ import (
|
|||
"fmt"
|
||||
"testing"
|
||||
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// mustParseStmt is a test helper that parses and validates a ruki statement,
|
||||
// failing the test on error.
|
||||
func mustParseStmt(t *testing.T, input string) *ruki.ValidatedStatement {
|
||||
t.Helper()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
stmt, err := parser.ParseAndValidateStatement(input, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ruki statement %q: %v", input, err)
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
|
||||
type navHarness struct {
|
||||
pb *pluginBase
|
||||
config *model.PluginConfig
|
||||
|
|
@ -79,14 +93,8 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
|
|||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
emptyFilter, err := filter.ParseFilter("status = 'done'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
todoFilter, err := filter.ParseFilter("status = 'ready'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
|
|
@ -102,9 +110,10 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 1)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -126,10 +135,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
todoFilter, err := filter.ParseFilter("status = 'ready'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
|
|
@ -145,9 +151,10 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -160,10 +167,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
|
||||
func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, err := filter.ParseFilter("status = 'done'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
|
|
@ -179,9 +183,10 @@ func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 2)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -517,7 +522,7 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -528,9 +533,10 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
navController := newMockNavigationController()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil, schema)
|
||||
|
||||
if !pc.HandleAction(ActionOpenFromPlugin) {
|
||||
t.Error("expected HandleAction(open) to return true when task is selected")
|
||||
|
|
@ -544,7 +550,7 @@ func TestPluginController_HandleOpenTask(t *testing.T) {
|
|||
|
||||
func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, _ := filter.ParseFilter("status = 'done'")
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
|
|
@ -552,9 +558,10 @@ func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
|
|||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
|
||||
|
||||
if pc.HandleAction(ActionOpenFromPlugin) {
|
||||
t.Error("expected false when no task is selected")
|
||||
|
|
@ -567,7 +574,7 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -577,9 +584,10 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
|
||||
|
||||
if !pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected HandleAction(delete) to return true")
|
||||
|
|
@ -592,7 +600,7 @@ func TestPluginController_HandleDeleteTask(t *testing.T) {
|
|||
|
||||
func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, _ := filter.ParseFilter("status = 'done'")
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
|
|
@ -600,9 +608,10 @@ func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
|
|||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil, schema)
|
||||
|
||||
if pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected false when no task is selected")
|
||||
|
|
@ -615,7 +624,7 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -626,12 +635,13 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnDelete(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "cannot delete"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline, schema)
|
||||
|
||||
if pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected false when delete is rejected")
|
||||
|
|
@ -645,7 +655,7 @@ func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
|||
|
||||
func TestPluginController_GetNameAndRegistry(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
todoFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "MyPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
|
|
@ -653,9 +663,10 @@ func TestPluginController_GetNameAndRegistry(t *testing.T) {
|
|||
pluginConfig := model.NewPluginConfig("MyPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
if pc.GetPluginName() != "MyPlugin" {
|
||||
t.Errorf("GetPluginName() = %q, want %q", pc.GetPluginName(), "MyPlugin")
|
||||
|
|
@ -671,20 +682,15 @@ func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
inProgressFilter, _ := filter.ParseFilter("status = 'in_progress'")
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
inProgressFilter := mustParseStmt(t, `select where status = "inProgress"`)
|
||||
inProgressAction := mustParseStmt(t, `update where id = id() set status = "inProgress"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{
|
||||
{Name: "Ready", Columns: 1, Filter: readyFilter},
|
||||
{
|
||||
Name: "InProgress", Columns: 1, Filter: inProgressFilter,
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "inProgress"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Name: "InProgress", Columns: 1, Filter: inProgressFilter, Action: inProgressAction},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
|
|
@ -693,12 +699,13 @@ func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
if pc.HandleAction(ActionMoveTaskRight) {
|
||||
t.Error("expected false when move is rejected by gate")
|
||||
|
|
@ -717,19 +724,17 @@ func TestPluginController_HandlePluginAction_Success(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
|
||||
},
|
||||
},
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: markDoneAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -738,9 +743,10 @@ func TestPluginController_HandlePluginAction_Success(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
if !pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected true for successful plugin action")
|
||||
|
|
@ -758,19 +764,17 @@ func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
|
|||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
markDoneAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
|
||||
},
|
||||
},
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: markDoneAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -780,20 +784,462 @@ func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
|
|||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline, schema)
|
||||
|
||||
if pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected false when plugin action is rejected by gate")
|
||||
}
|
||||
|
||||
// task should still have original status
|
||||
tk := taskStore.GetTask("T-1")
|
||||
if tk.Status != task.StatusReady {
|
||||
t.Errorf("expected status ready, got %s", tk.Status)
|
||||
tk2 := taskStore.GetTask("T-1")
|
||||
if tk2.Status != task.StatusReady {
|
||||
t.Errorf("expected status ready, got %s", tk2.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandlePluginAction_Create(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
createAction := mustParseStmt(t, `create title="New Task" status="ready" type="story" priority=3`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'c',
|
||||
Label: "Create",
|
||||
Action: createAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil, schema)
|
||||
|
||||
if !pc.HandleAction(pluginActionID('c')) {
|
||||
t.Error("expected true for successful create action")
|
||||
}
|
||||
|
||||
allTasks := taskStore.GetAllTasks()
|
||||
if len(allTasks) != 1 {
|
||||
t.Fatalf("expected 1 task after create, got %d", len(allTasks))
|
||||
}
|
||||
if allTasks[0].Title != "New Task" {
|
||||
t.Errorf("expected title 'New Task', got %q", allTasks[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandlePluginAction_Delete(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusDone, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
doneFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
deleteAction := mustParseStmt(t, `delete where status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Done", Columns: 1, Filter: doneFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'x',
|
||||
Label: "Delete Done",
|
||||
Action: deleteAction,
|
||||
},
|
||||
},
|
||||
}
|
||||
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('x')) {
|
||||
t.Error("expected true for successful delete action")
|
||||
}
|
||||
|
||||
if taskStore.GetTask("T-1") != nil {
|
||||
t.Error("task should have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandlePluginAction_NoMatchingRune(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"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{},
|
||||
}
|
||||
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)
|
||||
|
||||
if pc.HandleAction(pluginActionID('z')) {
|
||||
t.Error("expected false for non-matching plugin action rune")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandlePluginAction_NoSelectedTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
|
||||
emptyFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
updateAction := mustParseStmt(t, `update where id = id() set status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{Rune: 'd', Label: "Done", Action: updateAction},
|
||||
},
|
||||
}
|
||||
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)
|
||||
|
||||
if pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected false when no task is selected for update action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleMoveTask_NoActionOnTargetLane(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"`)
|
||||
doneFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{
|
||||
{Name: "Ready", Columns: 1, Filter: readyFilter},
|
||||
{Name: "Done", Columns: 1, Filter: doneFilter},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1, 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(ActionMoveTaskRight) {
|
||||
t.Error("expected false when target lane has no action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleMoveTask_Success(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"`)
|
||||
doneFilter := mustParseStmt(t, `select where status = "done"`)
|
||||
doneAction := 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},
|
||||
{Name: "Done", Columns: 1, Filter: doneFilter, Action: doneAction},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1, 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(ActionMoveTaskRight) {
|
||||
t.Error("expected true for successful move")
|
||||
}
|
||||
|
||||
tk := taskStore.GetTask("T-1")
|
||||
if tk.Status != "done" {
|
||||
t.Errorf("expected status done, got %s", tk.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleMoveTask_OutOfBounds(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"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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(ActionMoveTaskLeft) {
|
||||
t.Error("expected false for out-of-bounds move left")
|
||||
}
|
||||
if pc.HandleAction(ActionMoveTaskRight) {
|
||||
t.Error("expected false for out-of-bounds move right")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetFilteredTasksForLane_NilPluginDef(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
schema := rukiRuntime.NewSchema()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := &PluginController{
|
||||
pluginBase: pluginBase{
|
||||
taskStore: taskStore,
|
||||
mutationGate: gate,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: nil,
|
||||
schema: schema,
|
||||
},
|
||||
}
|
||||
|
||||
if tasks := pc.GetFilteredTasksForLane(0); tasks != nil {
|
||||
t.Error("expected nil for nil pluginDef")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetFilteredTasksForLane_OutOfRange(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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)
|
||||
|
||||
if tasks := pc.GetFilteredTasksForLane(-1); tasks != nil {
|
||||
t.Error("expected nil for negative lane")
|
||||
}
|
||||
if tasks := pc.GetFilteredTasksForLane(5); tasks != nil {
|
||||
t.Error("expected nil for out-of-range lane")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetFilteredTasksForLane_NilFilter(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "All", Columns: 1}},
|
||||
}
|
||||
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)
|
||||
|
||||
tasks := pc.GetFilteredTasksForLane(0)
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("expected all tasks when filter is nil, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetFilteredTasksForLane_WithSearchNarrowing(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Alpha", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-2", Title: "Beta", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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)
|
||||
|
||||
t1 := taskStore.GetTask("T-1")
|
||||
pluginConfig.SetSearchResults([]task.SearchResult{{Task: t1, Score: 1.0}}, "Alpha")
|
||||
|
||||
tasks := pc.GetFilteredTasksForLane(0)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected 1 task with search narrowing, got %d", len(tasks))
|
||||
}
|
||||
if tasks[0].ID != "T-1" {
|
||||
t.Errorf("expected T-1, got %s", tasks[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleAction_UnknownAction(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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)
|
||||
|
||||
if pc.HandleAction("nonexistent_action") {
|
||||
t.Error("expected false for unknown action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleSearch(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Alpha", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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)
|
||||
|
||||
pc.HandleSearch("Alpha")
|
||||
results := pluginConfig.GetSearchResults()
|
||||
if results == nil {
|
||||
t.Fatal("expected search results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_ShowNavigation(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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)
|
||||
|
||||
if !pc.ShowNavigation() {
|
||||
t.Error("PluginController.ShowNavigation() should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleToggleViewMode(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
readyFilter := mustParseStmt(t, `select where status = "ready"`)
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
}
|
||||
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)
|
||||
|
||||
before := pluginConfig.GetViewMode()
|
||||
if !pc.HandleAction(ActionToggleViewMode) {
|
||||
t.Error("expected true for toggle view mode")
|
||||
}
|
||||
after := pluginConfig.GetViewMode()
|
||||
if before == after {
|
||||
t.Error("view mode should change after toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginActionRune(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id ActionID
|
||||
want rune
|
||||
}{
|
||||
{"valid", pluginActionID('d'), 'd'},
|
||||
{"not a plugin action", "some_action", 0},
|
||||
{"empty suffix", ActionID(pluginActionPrefix), 0},
|
||||
{"multi-char suffix", ActionID(pluginActionPrefix + "ab"), 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := getPluginActionRune(tt.id); got != tt.want {
|
||||
t.Errorf("getPluginActionRune(%q) = %q, want %q", tt.id, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 1
|
||||
filter: status = 'backlog'
|
||||
action: status=backlog, tags-=[moved]
|
||||
filter: select where status = "backlog"
|
||||
action: update where id = id() set status="backlog" tags=tags-["moved"]
|
||||
- name: Done
|
||||
columns: 1
|
||||
filter: status = 'done'
|
||||
action: status=done, tags+=[moved]
|
||||
filter: select where status = "done"
|
||||
action: update where id = id() set status="done" tags=tags+["moved"]
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatalf("failed to write workflow.yaml: %v", err)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
)
|
||||
|
|
@ -25,6 +26,7 @@ func BuildControllers(
|
|||
plugins []plugin.Plugin,
|
||||
pluginConfigs map[string]*model.PluginConfig,
|
||||
statuslineConfig *model.StatuslineConfig,
|
||||
schema ruki.Schema,
|
||||
) *Controllers {
|
||||
navController := controller.NewNavigationController(app)
|
||||
taskController := controller.NewTaskController(taskStore, mutationGate, navController, statuslineConfig)
|
||||
|
|
@ -39,6 +41,7 @@ func BuildControllers(
|
|||
tp,
|
||||
navController,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,8 +107,11 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
headerConfig, layoutModel := InitHeaderAndLayoutModels()
|
||||
statuslineConfig := InitStatuslineModel(tikiStore)
|
||||
|
||||
// Phase 5.5: Ruki schema (needed by plugin parser and trigger system)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
|
||||
// Phase 6: Plugin system
|
||||
plugins, err := LoadPlugins()
|
||||
plugins, err := LoadPlugins(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -117,7 +120,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
triggerEngine, triggerCount, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName })
|
||||
if err != nil {
|
||||
|
|
@ -138,6 +140,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
plugins,
|
||||
pluginConfigs,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
|
||||
// Phase 8: Input routing
|
||||
|
|
@ -148,6 +151,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
taskStore,
|
||||
gate,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
|
||||
// Phase 9: View factory and layout
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// LoadPlugins loads plugins from disk. Returns an error if workflow files
|
||||
// exist but contain no valid view definitions.
|
||||
func LoadPlugins() ([]plugin.Plugin, error) {
|
||||
plugins, err := plugin.LoadPlugins()
|
||||
func LoadPlugins(schema ruki.Schema) ([]plugin.Plugin, error) {
|
||||
plugins, err := plugin.LoadPlugins(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
478
plugin/action.go
478
plugin/action.go
|
|
@ -1,478 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// LaneAction represents parsed lane actions.
|
||||
type LaneAction struct {
|
||||
Ops []LaneActionOp
|
||||
}
|
||||
|
||||
// LaneActionOp represents a single action operation.
|
||||
type LaneActionOp struct {
|
||||
Field ActionField
|
||||
Operator ActionOperator
|
||||
StrValue string
|
||||
IntValue int
|
||||
Tags []string
|
||||
DependsOn []string
|
||||
DueValue time.Time
|
||||
RecurrenceValue task.Recurrence
|
||||
}
|
||||
|
||||
// ActionField identifies a supported action field.
|
||||
type ActionField string
|
||||
|
||||
const (
|
||||
ActionFieldStatus ActionField = "status"
|
||||
ActionFieldType ActionField = "type"
|
||||
ActionFieldPriority ActionField = "priority"
|
||||
ActionFieldAssignee ActionField = "assignee"
|
||||
ActionFieldPoints ActionField = "points"
|
||||
ActionFieldTags ActionField = "tags"
|
||||
ActionFieldDependsOn ActionField = "dependsOn"
|
||||
ActionFieldDue ActionField = "due"
|
||||
ActionFieldRecurrence ActionField = "recurrence"
|
||||
)
|
||||
|
||||
// ActionOperator identifies a supported action operator.
|
||||
type ActionOperator string
|
||||
|
||||
const (
|
||||
ActionOperatorAssign ActionOperator = "="
|
||||
ActionOperatorAdd ActionOperator = "+="
|
||||
ActionOperatorRemove ActionOperator = "-="
|
||||
)
|
||||
|
||||
// ParseLaneAction parses a lane action string into operations.
|
||||
func ParseLaneAction(input string) (LaneAction, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return LaneAction{}, nil
|
||||
}
|
||||
|
||||
parts, err := splitTopLevelCommas(input)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
|
||||
ops := make([]LaneActionOp, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
return LaneAction{}, fmt.Errorf("empty action segment")
|
||||
}
|
||||
|
||||
field, op, value, err := parseActionSegment(part)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
|
||||
switch field {
|
||||
case ActionFieldTags:
|
||||
if op == ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("tags action only supports += or -=")
|
||||
}
|
||||
tags, err := parseTagsValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
Tags: tags,
|
||||
})
|
||||
case ActionFieldDependsOn:
|
||||
if op == ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("dependsOn action only supports += or -=")
|
||||
}
|
||||
deps, err := parseTagsValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
DependsOn: deps,
|
||||
})
|
||||
case ActionFieldPriority, ActionFieldPoints:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
intValue, err := parseIntValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
if field == ActionFieldPriority && !task.IsValidPriority(intValue) {
|
||||
return LaneAction{}, fmt.Errorf("priority value out of range: %d", intValue)
|
||||
}
|
||||
if field == ActionFieldPoints && !task.IsValidPoints(intValue) {
|
||||
return LaneAction{}, fmt.Errorf("points value out of range: %d", intValue)
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
IntValue: intValue,
|
||||
})
|
||||
case ActionFieldStatus:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
strValue, err := parseStringValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
if _, ok := task.ParseStatus(strValue); !ok {
|
||||
return LaneAction{}, fmt.Errorf("invalid status value %q", strValue)
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
StrValue: strValue,
|
||||
})
|
||||
case ActionFieldType:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
strValue, err := parseStringValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
if _, ok := task.ParseType(strValue); !ok {
|
||||
return LaneAction{}, fmt.Errorf("invalid type value %q", strValue)
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
StrValue: strValue,
|
||||
})
|
||||
case ActionFieldDue:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
dueValue, err := parseDateValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
DueValue: dueValue,
|
||||
})
|
||||
case ActionFieldRecurrence:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
recValue, err := parseRecurrenceValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
RecurrenceValue: recValue,
|
||||
})
|
||||
default:
|
||||
if op != ActionOperatorAssign {
|
||||
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
|
||||
}
|
||||
strValue, err := parseStringValue(value)
|
||||
if err != nil {
|
||||
return LaneAction{}, err
|
||||
}
|
||||
ops = append(ops, LaneActionOp{
|
||||
Field: field,
|
||||
Operator: op,
|
||||
StrValue: strValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return LaneAction{Ops: ops}, nil
|
||||
}
|
||||
|
||||
// ApplyLaneAction applies a parsed action to a task clone.
|
||||
func ApplyLaneAction(src *task.Task, action LaneAction, currentUser string) (*task.Task, error) {
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("task is nil")
|
||||
}
|
||||
|
||||
if len(action.Ops) == 0 {
|
||||
return src.Clone(), nil
|
||||
}
|
||||
|
||||
clone := src.Clone()
|
||||
for _, op := range action.Ops {
|
||||
switch op.Field {
|
||||
case ActionFieldStatus:
|
||||
clone.Status = task.MapStatus(op.StrValue)
|
||||
case ActionFieldType:
|
||||
clone.Type = task.NormalizeType(op.StrValue)
|
||||
case ActionFieldPriority:
|
||||
clone.Priority = op.IntValue
|
||||
case ActionFieldAssignee:
|
||||
assignee := op.StrValue
|
||||
if isCurrentUserToken(assignee) {
|
||||
if strings.TrimSpace(currentUser) == "" {
|
||||
return nil, fmt.Errorf("current user is not available for assignee")
|
||||
}
|
||||
assignee = currentUser
|
||||
}
|
||||
clone.Assignee = assignee
|
||||
case ActionFieldPoints:
|
||||
clone.Points = op.IntValue
|
||||
case ActionFieldTags:
|
||||
clone.Tags = applyTagOperation(clone.Tags, op.Operator, op.Tags)
|
||||
case ActionFieldDependsOn:
|
||||
clone.DependsOn = applyTagOperation(clone.DependsOn, op.Operator, op.DependsOn)
|
||||
case ActionFieldDue:
|
||||
clone.Due = op.DueValue
|
||||
case ActionFieldRecurrence:
|
||||
clone.Recurrence = op.RecurrenceValue
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported action field %q", op.Field)
|
||||
}
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func isCurrentUserToken(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), "CURRENT_USER")
|
||||
}
|
||||
|
||||
func parseActionSegment(segment string) (ActionField, ActionOperator, string, error) {
|
||||
opIdx, op := findOperator(segment)
|
||||
if opIdx == -1 {
|
||||
return "", "", "", fmt.Errorf("action segment missing operator: %q", segment)
|
||||
}
|
||||
|
||||
field := strings.TrimSpace(segment[:opIdx])
|
||||
value := strings.TrimSpace(segment[opIdx+len(op):])
|
||||
if field == "" || value == "" {
|
||||
return "", "", "", fmt.Errorf("invalid action segment: %q", segment)
|
||||
}
|
||||
|
||||
switch strings.ToLower(field) {
|
||||
case "status":
|
||||
return ActionFieldStatus, op, value, nil
|
||||
case "type":
|
||||
return ActionFieldType, op, value, nil
|
||||
case "priority":
|
||||
return ActionFieldPriority, op, value, nil
|
||||
case "assignee":
|
||||
return ActionFieldAssignee, op, value, nil
|
||||
case "points":
|
||||
return ActionFieldPoints, op, value, nil
|
||||
case "tags":
|
||||
return ActionFieldTags, op, value, nil
|
||||
case "dependson":
|
||||
return ActionFieldDependsOn, op, value, nil
|
||||
case "due":
|
||||
return ActionFieldDue, op, value, nil
|
||||
case "recurrence":
|
||||
return ActionFieldRecurrence, op, value, nil
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("unknown action field %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
func findOperator(segment string) (int, ActionOperator) {
|
||||
if idx := strings.Index(segment, "+="); idx != -1 {
|
||||
return idx, ActionOperatorAdd
|
||||
}
|
||||
if idx := strings.Index(segment, "-="); idx != -1 {
|
||||
return idx, ActionOperatorRemove
|
||||
}
|
||||
if idx := strings.Index(segment, "="); idx != -1 {
|
||||
return idx, ActionOperatorAssign
|
||||
}
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// unquote trims whitespace and strips surrounding single or double quotes.
|
||||
func unquote(raw string) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
if len(v) >= 2 {
|
||||
if (v[0] == '\'' && v[len(v)-1] == '\'') ||
|
||||
(v[0] == '"' && v[len(v)-1] == '"') {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func parseStringValue(raw string) (string, error) {
|
||||
value := unquote(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("string value is empty")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseDateValue parses a date value for the due field.
|
||||
// Empty string (or empty after unquoting) means clear the due date (returns zero time).
|
||||
// Otherwise parses as YYYY-MM-DD format.
|
||||
func parseDateValue(raw string) (time.Time, error) {
|
||||
value := unquote(raw)
|
||||
|
||||
// Empty string means clear the due date
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
// Parse as date
|
||||
parsed, ok := task.ParseDueDate(value)
|
||||
if !ok {
|
||||
return time.Time{}, fmt.Errorf("invalid date format %q (expected YYYY-MM-DD)", value)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseRecurrenceValue parses a recurrence value from an action expression.
|
||||
// Empty string (or empty after unquoting) clears the recurrence.
|
||||
// Otherwise parses as a cron pattern or display name.
|
||||
func parseRecurrenceValue(raw string) (task.Recurrence, error) {
|
||||
value := unquote(raw)
|
||||
|
||||
if value == "" {
|
||||
return task.RecurrenceNone, nil
|
||||
}
|
||||
|
||||
// try as cron pattern first
|
||||
if r, ok := task.ParseRecurrence(value); ok {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// try as display name
|
||||
r := task.RecurrenceFromDisplay(value)
|
||||
if r != task.RecurrenceNone {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
return task.RecurrenceNone, fmt.Errorf("invalid recurrence value %q", value)
|
||||
}
|
||||
|
||||
func parseIntValue(raw string) (int, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value %q", value)
|
||||
}
|
||||
return intValue, nil
|
||||
}
|
||||
|
||||
func parseTagsValue(raw string) ([]string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
|
||||
return nil, fmt.Errorf("tags value must be in brackets, got %q", value)
|
||||
}
|
||||
inner := strings.TrimSpace(value[1 : len(value)-1])
|
||||
if inner == "" {
|
||||
return nil, fmt.Errorf("tags list is empty")
|
||||
}
|
||||
parts, err := splitTopLevelCommas(inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
tag, err := parseStringValue(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func splitTopLevelCommas(input string) ([]string, error) {
|
||||
var parts []string
|
||||
start := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
bracketDepth := 0
|
||||
|
||||
for i, r := range input {
|
||||
switch r {
|
||||
case '\'':
|
||||
if !inDouble {
|
||||
inSingle = !inSingle
|
||||
}
|
||||
case '"':
|
||||
if !inSingle {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
case '[':
|
||||
if !inSingle && !inDouble {
|
||||
bracketDepth++
|
||||
}
|
||||
case ']':
|
||||
if !inSingle && !inDouble {
|
||||
if bracketDepth == 0 {
|
||||
return nil, fmt.Errorf("unexpected ']' in %q", input)
|
||||
}
|
||||
bracketDepth--
|
||||
}
|
||||
case ',':
|
||||
if !inSingle && !inDouble && bracketDepth == 0 {
|
||||
part := strings.TrimSpace(input[start:i])
|
||||
parts = append(parts, part)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inSingle || inDouble || bracketDepth != 0 {
|
||||
return nil, fmt.Errorf("unterminated quotes or brackets in %q", input)
|
||||
}
|
||||
|
||||
part := strings.TrimSpace(input[start:])
|
||||
parts = append(parts, part)
|
||||
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func applyTagOperation(current []string, op ActionOperator, tags []string) []string {
|
||||
switch op {
|
||||
case ActionOperatorAdd:
|
||||
return addTags(current, tags)
|
||||
case ActionOperatorRemove:
|
||||
return removeTags(current, tags)
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func addTags(current []string, tags []string) []string {
|
||||
existing := make(map[string]bool, len(current))
|
||||
for _, tag := range current {
|
||||
existing[tag] = true
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if !existing[tag] {
|
||||
current = append(current, tag)
|
||||
existing[tag] = true
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func removeTags(current []string, tags []string) []string {
|
||||
toRemove := make(map[string]bool, len(tags))
|
||||
for _, tag := range tags {
|
||||
toRemove[tag] = true
|
||||
}
|
||||
filtered := current[:0]
|
||||
for _, tag := range current {
|
||||
if !toRemove[tag] {
|
||||
filtered = append(filtered, tag)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
|
@ -1,648 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestSplitTopLevelCommas(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "simple split",
|
||||
input: "status=todo, type=bug",
|
||||
want: []string{"status=todo", "type=bug"},
|
||||
},
|
||||
{
|
||||
name: "comma in quotes",
|
||||
input: "assignee='O,Brien', status=done",
|
||||
want: []string{"assignee='O,Brien'", "status=done"},
|
||||
},
|
||||
{
|
||||
name: "comma in brackets",
|
||||
input: "tags+=[one,two], status=done",
|
||||
want: []string{"tags+=[one,two]", "status=done"},
|
||||
},
|
||||
{
|
||||
name: "mixed quotes and brackets",
|
||||
input: `tags+=[one,"two,three"], status=done`,
|
||||
want: []string{`tags+=[one,"two,three"]`, "status=done"},
|
||||
},
|
||||
{
|
||||
name: "unterminated quote",
|
||||
input: "status='todo, type=bug",
|
||||
wantErr: "unterminated quotes or brackets",
|
||||
},
|
||||
{
|
||||
name: "unterminated brackets",
|
||||
input: "tags+=[one,two, status=done",
|
||||
wantErr: "unterminated quotes or brackets",
|
||||
},
|
||||
{
|
||||
name: "unexpected closing bracket",
|
||||
input: "status=todo], type=bug",
|
||||
wantErr: "unexpected ']'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := splitTopLevelCommas(tc.input)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction(t *testing.T) {
|
||||
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review'], dependsOn+=[TIKI-ABC123]")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(action.Ops) != 7 {
|
||||
t.Fatalf("expected 7 ops, got %d", len(action.Ops))
|
||||
}
|
||||
|
||||
gotFields := []ActionField{
|
||||
action.Ops[0].Field,
|
||||
action.Ops[1].Field,
|
||||
action.Ops[2].Field,
|
||||
action.Ops[3].Field,
|
||||
action.Ops[4].Field,
|
||||
action.Ops[5].Field,
|
||||
action.Ops[6].Field,
|
||||
}
|
||||
wantFields := []ActionField{
|
||||
ActionFieldStatus,
|
||||
ActionFieldType,
|
||||
ActionFieldPriority,
|
||||
ActionFieldPoints,
|
||||
ActionFieldAssignee,
|
||||
ActionFieldTags,
|
||||
ActionFieldDependsOn,
|
||||
}
|
||||
if !reflect.DeepEqual(gotFields, wantFields) {
|
||||
t.Fatalf("expected fields %v, got %v", wantFields, gotFields)
|
||||
}
|
||||
|
||||
if action.Ops[0].StrValue != "done" {
|
||||
t.Fatalf("expected status value 'done', got %q", action.Ops[0].StrValue)
|
||||
}
|
||||
if action.Ops[1].StrValue != "bug" {
|
||||
t.Fatalf("expected type value 'bug', got %q", action.Ops[1].StrValue)
|
||||
}
|
||||
if action.Ops[2].IntValue != 2 {
|
||||
t.Fatalf("expected priority 2, got %d", action.Ops[2].IntValue)
|
||||
}
|
||||
if action.Ops[3].IntValue != 3 {
|
||||
t.Fatalf("expected points 3, got %d", action.Ops[3].IntValue)
|
||||
}
|
||||
if action.Ops[4].StrValue != "Alice" {
|
||||
t.Fatalf("expected assignee Alice, got %q", action.Ops[4].StrValue)
|
||||
}
|
||||
if !reflect.DeepEqual(action.Ops[5].Tags, []string{"frontend", "needs review"}) {
|
||||
t.Fatalf("expected tags [frontend needs review], got %v", action.Ops[5].Tags)
|
||||
}
|
||||
if !reflect.DeepEqual(action.Ops[6].DependsOn, []string{"TIKI-ABC123"}) {
|
||||
t.Fatalf("expected dependsOn [TIKI-ABC123], got %v", action.Ops[6].DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty segment",
|
||||
input: "status=done,,type=bug",
|
||||
wantErr: "empty action segment",
|
||||
},
|
||||
{
|
||||
name: "missing operator",
|
||||
input: "statusdone",
|
||||
wantErr: "missing operator",
|
||||
},
|
||||
{
|
||||
name: "tags assign not allowed",
|
||||
input: "tags=[one]",
|
||||
wantErr: "tags action only supports",
|
||||
},
|
||||
{
|
||||
name: "status add not allowed",
|
||||
input: "status+=done",
|
||||
wantErr: "status action only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
input: "owner=me",
|
||||
wantErr: "unknown action field",
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
input: "status=unknown",
|
||||
wantErr: "invalid status value",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
input: "type=unknown",
|
||||
wantErr: "invalid type value",
|
||||
},
|
||||
{
|
||||
name: "priority out of range",
|
||||
input: "priority=10",
|
||||
wantErr: "priority value out of range",
|
||||
},
|
||||
{
|
||||
name: "points out of range",
|
||||
input: "points=-1",
|
||||
wantErr: "points value out of range",
|
||||
},
|
||||
{
|
||||
name: "tags missing brackets",
|
||||
input: "tags+={one}",
|
||||
wantErr: "tags value must be in brackets",
|
||||
},
|
||||
{
|
||||
name: "dependsOn assign not allowed",
|
||||
input: "dependsOn=[TIKI-ABC123]",
|
||||
wantErr: "dependsOn action only supports",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseLaneAction(tc.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
Tags: []string{"existing"},
|
||||
DependsOn: []string{"TIKI-AAA111"},
|
||||
Assignee: "Bob",
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee=Alice, tags+=[moved], dependsOn+=[TIKI-BBB222]")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected parse error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected apply error: %v", err)
|
||||
}
|
||||
|
||||
if updated.Status != task.StatusDone {
|
||||
t.Fatalf("expected status done, got %v", updated.Status)
|
||||
}
|
||||
if updated.Type != task.TypeBug {
|
||||
t.Fatalf("expected type bug, got %v", updated.Type)
|
||||
}
|
||||
if updated.Priority != 2 {
|
||||
t.Fatalf("expected priority 2, got %d", updated.Priority)
|
||||
}
|
||||
if updated.Points != 3 {
|
||||
t.Fatalf("expected points 3, got %d", updated.Points)
|
||||
}
|
||||
if updated.Assignee != "Alice" {
|
||||
t.Fatalf("expected assignee Alice, got %q", updated.Assignee)
|
||||
}
|
||||
if !reflect.DeepEqual(updated.Tags, []string{"existing", "moved"}) {
|
||||
t.Fatalf("expected tags [existing moved], got %v", updated.Tags)
|
||||
}
|
||||
if !reflect.DeepEqual(updated.DependsOn, []string{"TIKI-AAA111", "TIKI-BBB222"}) {
|
||||
t.Fatalf("expected dependsOn [TIKI-AAA111 TIKI-BBB222], got %v", updated.DependsOn)
|
||||
}
|
||||
if base.Status != task.StatusBacklog {
|
||||
t.Fatalf("expected base task unchanged, got %v", base.Status)
|
||||
}
|
||||
if !reflect.DeepEqual(base.Tags, []string{"existing"}) {
|
||||
t.Fatalf("expected base tags unchanged, got %v", base.Tags)
|
||||
}
|
||||
if !reflect.DeepEqual(base.DependsOn, []string{"TIKI-AAA111"}) {
|
||||
t.Fatalf("expected base dependsOn unchanged, got %v", base.DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_InvalidResult(t *testing.T) {
|
||||
// ApplyLaneAction no longer validates the result — the gate does that
|
||||
// at persistence time. This test verifies the action is applied as-is.
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
}
|
||||
|
||||
action := LaneAction{
|
||||
Ops: []LaneActionOp{
|
||||
{
|
||||
Field: ActionFieldPriority,
|
||||
Operator: ActionOperatorAssign,
|
||||
IntValue: 99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Priority != 99 {
|
||||
t.Errorf("expected priority 99, got %d", result.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_AssigneeCurrentUser(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
Assignee: "Bob",
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction("assignee=CURRENT_USER")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected parse error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ApplyLaneAction(base, action, "Alex")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected apply error: %v", err)
|
||||
}
|
||||
if updated.Assignee != "Alex" {
|
||||
t.Fatalf("expected assignee Alex, got %q", updated.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_AssigneeCurrentUserMissing(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: task.PriorityMedium,
|
||||
Points: 1,
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction("assignee=CURRENT_USER")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected parse error: %v", err)
|
||||
}
|
||||
|
||||
_, err = ApplyLaneAction(base, action, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing current user")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "current user") {
|
||||
t.Fatalf("expected current user error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_Due(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
checkFunc func(*testing.T, LaneAction)
|
||||
}{
|
||||
{
|
||||
name: "due with valid date",
|
||||
input: "due=2026-03-16",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
if op.Field != ActionFieldDue {
|
||||
t.Errorf("expected field due, got %v", op.Field)
|
||||
}
|
||||
if op.Operator != ActionOperatorAssign {
|
||||
t.Errorf("expected operator =, got %v", op.Operator)
|
||||
}
|
||||
expectedDate := "2026-03-16"
|
||||
gotDate := op.DueValue.Format(task.DateFormat)
|
||||
if gotDate != expectedDate {
|
||||
t.Errorf("expected date %v, got %v", expectedDate, gotDate)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "due with quoted date",
|
||||
input: "due='2026-03-16'",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
expectedDate := "2026-03-16"
|
||||
gotDate := op.DueValue.Format(task.DateFormat)
|
||||
if gotDate != expectedDate {
|
||||
t.Errorf("expected date %v, got %v", expectedDate, gotDate)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "due with empty string (clear)",
|
||||
input: "due=''",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
if !op.DueValue.IsZero() {
|
||||
t.Errorf("expected zero time for empty string, got %v", op.DueValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "due with invalid date format",
|
||||
input: "due=03/16/2026",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "due with += operator",
|
||||
input: "due+=2026-03-16",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "due with -= operator",
|
||||
input: "due-=2026-03-16",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action, err := ParseLaneAction(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseLaneAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.checkFunc != nil {
|
||||
tt.checkFunc(t, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_Recurrence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
checkFunc func(*testing.T, LaneAction)
|
||||
}{
|
||||
{
|
||||
name: "recurrence with cron pattern",
|
||||
input: "recurrence='0 0 * * MON'",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
if len(action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(action.Ops))
|
||||
}
|
||||
op := action.Ops[0]
|
||||
if op.Field != ActionFieldRecurrence {
|
||||
t.Errorf("expected field recurrence, got %v", op.Field)
|
||||
}
|
||||
if op.RecurrenceValue != task.Recurrence("0 0 * * MON") {
|
||||
t.Errorf("expected '0 0 * * MON', got %q", op.RecurrenceValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recurrence with display name",
|
||||
input: "recurrence=Daily",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
op := action.Ops[0]
|
||||
if op.RecurrenceValue != task.RecurrenceDaily {
|
||||
t.Errorf("expected daily cron, got %q", op.RecurrenceValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recurrence clear with empty string",
|
||||
input: "recurrence=''",
|
||||
wantErr: false,
|
||||
checkFunc: func(t *testing.T, action LaneAction) {
|
||||
op := action.Ops[0]
|
||||
if op.RecurrenceValue != task.RecurrenceNone {
|
||||
t.Errorf("expected empty recurrence, got %q", op.RecurrenceValue)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recurrence invalid value",
|
||||
input: "recurrence=biweekly",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "recurrence += not allowed",
|
||||
input: "recurrence+=Daily",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action, err := ParseLaneAction(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseLaneAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err == nil && tt.checkFunc != nil {
|
||||
tt.checkFunc(t, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_Recurrence(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TIKI-TEST01",
|
||||
Title: "Test Task",
|
||||
Type: task.TypeStory,
|
||||
Status: "backlog",
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
t.Run("set recurrence", func(t *testing.T) {
|
||||
action, err := ParseLaneAction("recurrence='0 0 * * MON'")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
if result.Recurrence != task.Recurrence("0 0 * * MON") {
|
||||
t.Errorf("expected '0 0 * * MON', got %q", result.Recurrence)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear recurrence", func(t *testing.T) {
|
||||
baseWithRec := base.Clone()
|
||||
baseWithRec.Recurrence = "0 0 * * MON"
|
||||
|
||||
action, err := ParseLaneAction("recurrence=''")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
result, err := ApplyLaneAction(baseWithRec, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
if result.Recurrence != task.RecurrenceNone {
|
||||
t.Errorf("expected empty recurrence, got %q", result.Recurrence)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_Due(t *testing.T) {
|
||||
base := &task.Task{
|
||||
ID: "TIKI-TEST01",
|
||||
Title: "Test Task",
|
||||
Type: task.TypeStory,
|
||||
Status: "backlog",
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
t.Run("set due date", func(t *testing.T) {
|
||||
action, err := ParseLaneAction("due=2026-03-16")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
expectedDate := "2026-03-16"
|
||||
gotDate := result.Due.Format(task.DateFormat)
|
||||
if gotDate != expectedDate {
|
||||
t.Errorf("expected due date %v, got %v", expectedDate, gotDate)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear due date", func(t *testing.T) {
|
||||
baseWithDue := base.Clone()
|
||||
baseWithDue.Due, _ = time.Parse(task.DateFormat, "2026-03-16")
|
||||
|
||||
action, err := ParseLaneAction("due=''")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := ApplyLaneAction(baseWithDue, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLaneAction() error = %v", err)
|
||||
}
|
||||
|
||||
if !result.Due.IsZero() {
|
||||
t.Errorf("expected zero due date, got %v", result.Due)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseLaneAction_EmptyString(t *testing.T) {
|
||||
action, err := ParseLaneAction("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(action.Ops) != 0 {
|
||||
t.Errorf("expected 0 ops for empty input, got %d", len(action.Ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_InvalidInteger(t *testing.T) {
|
||||
_, err := ParseLaneAction("priority=abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-integer priority")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid integer") {
|
||||
t.Errorf("expected 'invalid integer' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_NilTask(t *testing.T) {
|
||||
action := LaneAction{Ops: []LaneActionOp{{Field: ActionFieldStatus, Operator: ActionOperatorAssign, StrValue: "done"}}}
|
||||
_, err := ApplyLaneAction(nil, action, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil task")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "task is nil") {
|
||||
t.Errorf("expected 'task is nil' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_NoOps(t *testing.T) {
|
||||
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
|
||||
result, err := ApplyLaneAction(base, LaneAction{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == base {
|
||||
t.Error("expected clone, not original pointer")
|
||||
}
|
||||
if result.Title != "Task" {
|
||||
t.Errorf("expected title 'Task', got %q", result.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_UnsupportedField(t *testing.T) {
|
||||
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
|
||||
action := LaneAction{Ops: []LaneActionOp{{Field: "bogus", Operator: ActionOperatorAssign, StrValue: "x"}}}
|
||||
_, err := ApplyLaneAction(base, action, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported action field") {
|
||||
t.Errorf("expected 'unsupported action field' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package plugin
|
|||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// Plugin interface defines the common methods for all plugins
|
||||
|
|
@ -64,7 +64,6 @@ func (p *BasePlugin) IsDefault() bool {
|
|||
type TikiPlugin struct {
|
||||
BasePlugin
|
||||
Lanes []TikiLane // lane definitions for this plugin
|
||||
Sort []SortRule // parsed sort rules (nil = default sort)
|
||||
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
|
||||
Actions []PluginAction // shortcut actions applied to the selected task
|
||||
TaskID string // optional tiki associated with this plugin (code-only, not from workflow config)
|
||||
|
|
@ -89,7 +88,7 @@ type PluginActionConfig struct {
|
|||
type PluginAction struct {
|
||||
Rune rune
|
||||
Label string
|
||||
Action LaneAction
|
||||
Action *ruki.ValidatedStatement
|
||||
}
|
||||
|
||||
// PluginLaneConfig represents a lane in YAML or config definitions.
|
||||
|
|
@ -106,6 +105,6 @@ type TikiLane struct {
|
|||
Name string
|
||||
Columns int
|
||||
Width int // lane width as a percentage (0 = equal share of remaining space)
|
||||
Filter filter.FilterExpr
|
||||
Action LaneAction
|
||||
Filter *ruki.ValidatedStatement
|
||||
Action *ruki.ValidatedStatement
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/duration"
|
||||
)
|
||||
|
||||
// durationPattern matches a number followed by a duration unit, with optional plural "s".
|
||||
var durationPattern = regexp.MustCompile(`^(\d+)(` + duration.Pattern() + `)s?$`)
|
||||
|
||||
// IsDurationLiteral checks if a string is a valid duration literal.
|
||||
func IsDurationLiteral(s string) bool {
|
||||
return durationPattern.MatchString(strings.ToLower(s))
|
||||
}
|
||||
|
||||
// ParseDuration parses a duration literal like "24hour" or "1week".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
val, unit, err := duration.Parse(strings.ToLower(s))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
return duration.ToDuration(val, unit)
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// parseComparison parses comparison expressions like: field op value
|
||||
func (p *filterParser) parseComparison() (FilterExpr, error) {
|
||||
// Parse left side (typically a field name or time expression)
|
||||
leftValue, leftIsTimeExpr, err := p.parseValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get operator
|
||||
tok := p.current()
|
||||
if tok.Type != TokenOperator {
|
||||
return nil, fmt.Errorf("expected comparison operator, got %s", tok.Value)
|
||||
}
|
||||
op := tok.Value
|
||||
p.advance()
|
||||
|
||||
// Parse right side
|
||||
rightValue, rightIsTimeExpr, err := p.parseValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build comparison expression
|
||||
// If left side is a time expression like "NOW - CreatedAt", we need to handle it specially
|
||||
if leftIsTimeExpr {
|
||||
leftTimeExpr, _ := leftValue.(*TimeExpr)
|
||||
// The comparison becomes: (NOW - CreatedAt) < 24hour
|
||||
// which means: time.Since(CreatedAt) < 24hour
|
||||
return &CompareExpr{
|
||||
Field: "time_expr",
|
||||
Op: op,
|
||||
Value: &timeExprCompare{left: leftTimeExpr, right: rightValue},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Normal field comparison
|
||||
fieldName, ok := leftValue.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected field name on left side of comparison")
|
||||
}
|
||||
|
||||
// If right side is a time expression, wrap it
|
||||
if rightIsTimeExpr {
|
||||
return &CompareExpr{
|
||||
Field: fieldName,
|
||||
Op: op,
|
||||
Value: rightValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &CompareExpr{
|
||||
Field: fieldName,
|
||||
Op: op,
|
||||
Value: rightValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseValueGeneric parses a value with optional time expression support
|
||||
// allowTimeExpr controls whether to parse time expressions like "NOW - 24hour"
|
||||
// Returns the value and whether it's a time expression
|
||||
func (p *filterParser) parseValueGeneric(allowTimeExpr bool) (interface{}, bool, error) {
|
||||
tok := p.current()
|
||||
|
||||
switch tok.Type {
|
||||
case TokenString:
|
||||
p.advance()
|
||||
return tok.Value, false, nil
|
||||
|
||||
case TokenNumber:
|
||||
p.advance()
|
||||
num, err := strconv.Atoi(tok.Value)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("invalid number: %s", tok.Value)
|
||||
}
|
||||
return num, false, nil
|
||||
|
||||
case TokenDuration:
|
||||
if !allowTimeExpr {
|
||||
return nil, false, fmt.Errorf("duration not allowed in this context")
|
||||
}
|
||||
p.advance()
|
||||
dur, err := ParseDuration(tok.Value)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &DurationValue{Duration: dur}, false, nil
|
||||
|
||||
case TokenIdent:
|
||||
ident := tok.Value
|
||||
p.advance()
|
||||
|
||||
// Check if this is a time expression (only if allowed)
|
||||
if allowTimeExpr && isTimeField(ident) {
|
||||
// Check if followed by + or -
|
||||
if p.current().Type == TokenOperator {
|
||||
opTok := p.current()
|
||||
if opTok.Value == "+" || opTok.Value == "-" {
|
||||
p.advance()
|
||||
// Parse the operand (duration or another time field)
|
||||
operand, _, err := p.parseTimeOperand()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &TimeExpr{Base: ident, Op: opTok.Value, Operand: operand}, true, nil
|
||||
}
|
||||
}
|
||||
// Just a time field reference without arithmetic
|
||||
return &TimeExpr{Base: ident}, true, nil
|
||||
}
|
||||
|
||||
// Regular identifier (field name or special value like CURRENT_USER)
|
||||
return ident, false, nil
|
||||
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unexpected token in value: %s", tok.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// parseValue parses a value for comparisons (allows time expressions)
|
||||
func (p *filterParser) parseValue() (interface{}, bool, error) {
|
||||
return p.parseValueGeneric(true)
|
||||
}
|
||||
|
||||
// parseTimeOperand parses the operand of a time expression (duration or field name)
|
||||
func (p *filterParser) parseTimeOperand() (interface{}, bool, error) {
|
||||
tok := p.current()
|
||||
|
||||
switch tok.Type {
|
||||
case TokenDuration:
|
||||
p.advance()
|
||||
dur, err := ParseDuration(tok.Value)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return dur, false, nil
|
||||
|
||||
case TokenIdent:
|
||||
ident := tok.Value
|
||||
p.advance()
|
||||
|
||||
// Time field names
|
||||
if isTimeField(ident) {
|
||||
return ident, true, nil
|
||||
}
|
||||
|
||||
return nil, false, fmt.Errorf("expected duration or time field, got: %s", ident)
|
||||
|
||||
default:
|
||||
return nil, false, fmt.Errorf("expected duration or time field, got: %s", tok.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// parseInExpr parses: field IN [val1, val2, ...] or field NOT IN [...]
|
||||
// This is called when we detect the IN pattern during primary expression parsing
|
||||
func (p *filterParser) parseInExpr(fieldName string, isNotIn bool) (FilterExpr, error) {
|
||||
// Expect opening bracket
|
||||
if err := p.expect(TokenLBracket); err != nil {
|
||||
return nil, fmt.Errorf("expected '[' after IN: %w", err)
|
||||
}
|
||||
|
||||
// Parse list of values
|
||||
var values []interface{}
|
||||
|
||||
// Handle empty list
|
||||
if p.current().Type == TokenRBracket {
|
||||
p.advance()
|
||||
return &InExpr{Field: fieldName, Not: isNotIn, Values: values}, nil
|
||||
}
|
||||
|
||||
for {
|
||||
// Parse a value (string, number, or identifier like CURRENT_USER)
|
||||
val, err := p.parseListValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, val)
|
||||
|
||||
// Check for comma (more values) or closing bracket (done)
|
||||
tok := p.current()
|
||||
if tok.Type == TokenRBracket {
|
||||
p.advance()
|
||||
break
|
||||
}
|
||||
if tok.Type == TokenComma {
|
||||
p.advance()
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("expected ',' or ']' in list, got: %s", tok.Value)
|
||||
}
|
||||
|
||||
return &InExpr{Field: fieldName, Not: isNotIn, Values: values}, nil
|
||||
}
|
||||
|
||||
// parseListValue parses a single value in a list (string, number, or identifier)
|
||||
// Does not allow durations or time expressions
|
||||
func (p *filterParser) parseListValue() (interface{}, error) {
|
||||
val, _, err := p.parseValueGeneric(false)
|
||||
return val, err
|
||||
}
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// FilterExpr represents a filter expression that can be evaluated against a task
|
||||
type FilterExpr interface {
|
||||
Evaluate(task *task.Task, now time.Time, currentUser string) bool
|
||||
}
|
||||
|
||||
// BinaryExpr represents AND, OR operations
|
||||
type BinaryExpr struct {
|
||||
Op string // "AND", "OR"
|
||||
Left FilterExpr
|
||||
Right FilterExpr
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr
|
||||
func (b *BinaryExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
switch strings.ToUpper(b.Op) {
|
||||
case "AND":
|
||||
return b.Left.Evaluate(task, now, currentUser) && b.Right.Evaluate(task, now, currentUser)
|
||||
case "OR":
|
||||
return b.Left.Evaluate(task, now, currentUser) || b.Right.Evaluate(task, now, currentUser)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UnaryExpr represents NOT operation
|
||||
type UnaryExpr struct {
|
||||
Op string // "NOT"
|
||||
Expr FilterExpr
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr
|
||||
func (u *UnaryExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
if strings.ToUpper(u.Op) == "NOT" {
|
||||
return !u.Expr.Evaluate(task, now, currentUser)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CompareExpr represents comparisons like status = 'ready' or Priority < 3
|
||||
type CompareExpr struct {
|
||||
Field string // "status", "type", "assignee", "priority", "points", "createdat", "updatedat", "tags"
|
||||
Op string // "=", "==", "!=", ">", "<", ">=", "<="
|
||||
Value interface{} // string, int, or TimeExpr
|
||||
}
|
||||
|
||||
// InExpr represents IN/NOT IN operations like: tags IN ['ui', 'charts', 'viz']
|
||||
type InExpr struct {
|
||||
Field string // "status", "type", "tags", etc.
|
||||
Not bool // true for NOT IN, false for IN
|
||||
Values []interface{} // List of values to check against (strings, ints, etc.)
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr for InExpr
|
||||
func (i *InExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
// Handle CURRENT_USER and normalize status literals in the values list
|
||||
isStatus := strings.ToLower(i.Field) == "status"
|
||||
resolvedValues := make([]interface{}, len(i.Values))
|
||||
for idx, val := range i.Values {
|
||||
if strVal, ok := val.(string); ok && strings.ToUpper(strVal) == "CURRENT_USER" {
|
||||
resolvedValues[idx] = currentUser
|
||||
} else if isStatus {
|
||||
if strVal, ok := val.(string); ok {
|
||||
resolvedValues[idx] = string(workflow.NormalizeStatusKey(strVal))
|
||||
} else {
|
||||
resolvedValues[idx] = val
|
||||
}
|
||||
} else {
|
||||
resolvedValues[idx] = val
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for array fields (tags, dependsOn)
|
||||
fieldLower := strings.ToLower(i.Field)
|
||||
if fieldLower == "tags" || fieldLower == "tag" || fieldLower == "dependson" {
|
||||
var arrayField []string
|
||||
switch fieldLower {
|
||||
case "tags", "tag":
|
||||
arrayField = task.Tags
|
||||
case "dependson":
|
||||
arrayField = task.DependsOn
|
||||
}
|
||||
result := evaluateTagsInComparison(arrayField, resolvedValues)
|
||||
if i.Not {
|
||||
return !result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// For non-array fields, check if field value is in the list
|
||||
fieldValue := getTaskAttribute(task, i.Field)
|
||||
result := valueInList(fieldValue, resolvedValues)
|
||||
if i.Not {
|
||||
return !result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Evaluate implements FilterExpr for CompareExpr
|
||||
func (c *CompareExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bool {
|
||||
// Handle time expression comparisons (e.g., NOW - CreatedAt < 24hour)
|
||||
if c.Field == "time_expr" {
|
||||
return c.evaluateTimeExpr(task, now)
|
||||
}
|
||||
|
||||
fieldValue := getTaskAttribute(task, c.Field)
|
||||
compareValue := c.Value
|
||||
|
||||
// Handle CURRENT_USER special value
|
||||
if strVal, ok := compareValue.(string); ok && strings.ToUpper(strVal) == "CURRENT_USER" {
|
||||
compareValue = currentUser
|
||||
}
|
||||
|
||||
// normalize status literals so legacy forms (e.g. "in_progress") match camelCase keys
|
||||
if strings.ToLower(c.Field) == "status" {
|
||||
if strVal, ok := compareValue.(string); ok {
|
||||
compareValue = string(workflow.NormalizeStatusKey(strVal))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle TimeExpr (for NOW - CreatedAt type comparisons)
|
||||
if timeExpr, ok := compareValue.(*TimeExpr); ok {
|
||||
compareValue = timeExpr.Evaluate(task, now)
|
||||
}
|
||||
|
||||
// Handle DurationValue
|
||||
if dv, ok := compareValue.(*DurationValue); ok {
|
||||
compareValue = dv.Duration
|
||||
}
|
||||
|
||||
// Handle array fields specially - check if value is in the list
|
||||
fieldLower := strings.ToLower(c.Field)
|
||||
if fieldLower == "tags" || fieldLower == "tag" {
|
||||
return evaluateTagComparison(task.Tags, c.Op, compareValue)
|
||||
}
|
||||
if fieldLower == "dependson" {
|
||||
return evaluateTagComparison(task.DependsOn, c.Op, compareValue)
|
||||
}
|
||||
|
||||
return compare(fieldValue, c.Op, compareValue)
|
||||
}
|
||||
|
||||
// evaluateTimeExpr handles time expression comparisons like "NOW - CreatedAt < 24hour"
|
||||
func (c *CompareExpr) evaluateTimeExpr(task *task.Task, now time.Time) bool {
|
||||
tec, ok := c.Value.(*timeExprCompare)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
leftValue := tec.left.Evaluate(task, now)
|
||||
rightValue := tec.right
|
||||
|
||||
// Handle DurationValue
|
||||
if dv, ok := rightValue.(*DurationValue); ok {
|
||||
rightValue = dv.Duration
|
||||
}
|
||||
|
||||
return compare(leftValue, c.Op, rightValue)
|
||||
}
|
||||
|
||||
// TimeExpr represents time arithmetic like NOW - 24hour or NOW - CreatedAt
|
||||
type TimeExpr struct {
|
||||
Base string // "NOW", "CreatedAt", "UpdatedAt", "Due"
|
||||
Op string // "+", "-"
|
||||
Operand interface{} // time.Duration or field name string
|
||||
}
|
||||
|
||||
// Evaluate returns the computed time or duration value
|
||||
func (t *TimeExpr) Evaluate(task *task.Task, now time.Time) interface{} {
|
||||
var baseTime time.Time
|
||||
|
||||
switch strings.ToLower(t.Base) {
|
||||
case "now":
|
||||
baseTime = now
|
||||
case "createdat":
|
||||
baseTime = task.CreatedAt
|
||||
case "updatedat":
|
||||
baseTime = task.UpdatedAt
|
||||
case "due":
|
||||
if task.Due.IsZero() {
|
||||
return nil
|
||||
}
|
||||
baseTime = task.Due
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.Op == "" {
|
||||
return baseTime
|
||||
}
|
||||
|
||||
// Handle duration operand
|
||||
if dur, ok := t.Operand.(time.Duration); ok {
|
||||
if t.Op == "-" {
|
||||
return baseTime.Add(-dur)
|
||||
}
|
||||
return baseTime.Add(dur)
|
||||
}
|
||||
|
||||
// Handle field name operand (e.g., NOW - CreatedAt returns duration)
|
||||
if fieldName, ok := t.Operand.(string); ok {
|
||||
var otherTime time.Time
|
||||
switch strings.ToLower(fieldName) {
|
||||
case "now":
|
||||
otherTime = now
|
||||
case "createdat":
|
||||
otherTime = task.CreatedAt
|
||||
case "updatedat":
|
||||
otherTime = task.UpdatedAt
|
||||
case "due":
|
||||
if task.Due.IsZero() {
|
||||
return nil
|
||||
}
|
||||
otherTime = task.Due
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.Op == "-" {
|
||||
return baseTime.Sub(otherTime)
|
||||
}
|
||||
// Addition of times doesn't make sense, return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return baseTime
|
||||
}
|
||||
|
||||
// DurationValue represents a parsed duration for comparison
|
||||
type DurationValue struct {
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// timeExprCompare wraps a time expression comparison for evaluation
|
||||
type timeExprCompare struct {
|
||||
left *TimeExpr
|
||||
right interface{}
|
||||
}
|
||||
|
||||
// getTaskAttribute returns the value of a task field by name
|
||||
func getTaskAttribute(task *task.Task, field string) interface{} {
|
||||
switch strings.ToLower(field) {
|
||||
case "status":
|
||||
return string(task.Status)
|
||||
case "type":
|
||||
return string(task.Type)
|
||||
case "assignee":
|
||||
return task.Assignee
|
||||
case "priority":
|
||||
return task.Priority
|
||||
case "points":
|
||||
return task.Points
|
||||
case "createdat":
|
||||
return task.CreatedAt
|
||||
case "updatedat":
|
||||
return task.UpdatedAt
|
||||
case "tags":
|
||||
return task.Tags
|
||||
case "dependson":
|
||||
return task.DependsOn
|
||||
case "due":
|
||||
return task.Due
|
||||
case "recurrence":
|
||||
return string(task.Recurrence)
|
||||
case "id":
|
||||
return task.ID
|
||||
case "title":
|
||||
return task.Title
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateTagComparison checks if a tag matches the comparison
|
||||
func evaluateTagComparison(tags []string, op string, value interface{}) bool {
|
||||
strVal, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any tag matches
|
||||
found := false
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, strVal) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "=", "==":
|
||||
return found
|
||||
case "!=":
|
||||
return !found
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateTagsInComparison checks if ANY task tag matches ANY value in the list
|
||||
// Semantics: task.Tags ∩ values != ∅
|
||||
func evaluateTagsInComparison(taskTags []string, values []interface{}) bool {
|
||||
for _, taskTag := range taskTags {
|
||||
for _, val := range values {
|
||||
if strVal, ok := val.(string); ok {
|
||||
if strings.EqualFold(taskTag, strVal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// valueInList checks if a single value exists in a list of values
|
||||
func valueInList(fieldValue interface{}, values []interface{}) bool {
|
||||
for _, val := range values {
|
||||
// String comparison (case-insensitive)
|
||||
if fvStr, ok := fieldValue.(string); ok {
|
||||
if valStr, ok := val.(string); ok {
|
||||
if strings.EqualFold(fvStr, valStr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Integer comparison
|
||||
if fvInt, ok := fieldValue.(int); ok {
|
||||
if valInt, ok := val.(int); ok {
|
||||
if fvInt == valInt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Direct equality for other types
|
||||
if fieldValue == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compare compares two values using the given operator
|
||||
func compare(left interface{}, op string, right interface{}) bool {
|
||||
// Normalize operator
|
||||
if op == "==" {
|
||||
op = "="
|
||||
}
|
||||
|
||||
// String comparison
|
||||
if leftStr, ok := left.(string); ok {
|
||||
rightStr, ok := right.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return compareStrings(leftStr, op, rightStr)
|
||||
}
|
||||
|
||||
// Integer comparison
|
||||
if leftInt, ok := left.(int); ok {
|
||||
rightInt, ok := right.(int)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return compareInts(leftInt, op, rightInt)
|
||||
}
|
||||
|
||||
// Time comparison
|
||||
if leftTime, ok := left.(time.Time); ok {
|
||||
if rightTime, ok := right.(time.Time); ok {
|
||||
return compareTimes(leftTime, op, rightTime)
|
||||
}
|
||||
// Coerce string to date for comparison (e.g., due = '2026-03-16')
|
||||
if rightStr, ok := right.(string); ok {
|
||||
if parsed, ok := task.ParseDueDate(rightStr); ok {
|
||||
return compareTimes(leftTime, op, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Duration comparison
|
||||
if leftDur, ok := left.(time.Duration); ok {
|
||||
if rightDur, ok := right.(time.Duration); ok {
|
||||
return compareDurations(leftDur, op, rightDur)
|
||||
}
|
||||
// Compare duration with DurationValue
|
||||
if rightDurVal, ok := right.(*DurationValue); ok {
|
||||
return compareDurations(leftDur, op, rightDurVal.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func compareStrings(left, op, right string) bool {
|
||||
// Case-insensitive comparison
|
||||
left = strings.ToLower(left)
|
||||
right = strings.ToLower(right)
|
||||
|
||||
switch op {
|
||||
case "=":
|
||||
return left == right
|
||||
case "!=":
|
||||
return left != right
|
||||
case ">":
|
||||
return left > right
|
||||
case "<":
|
||||
return left < right
|
||||
case ">=":
|
||||
return left >= right
|
||||
case "<=":
|
||||
return left <= right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareInts(left int, op string, right int) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return left == right
|
||||
case "!=":
|
||||
return left != right
|
||||
case ">":
|
||||
return left > right
|
||||
case "<":
|
||||
return left < right
|
||||
case ">=":
|
||||
return left >= right
|
||||
case "<=":
|
||||
return left <= right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareTimes(left time.Time, op string, right time.Time) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return left.Equal(right)
|
||||
case "!=":
|
||||
return !left.Equal(right)
|
||||
case ">":
|
||||
return left.After(right)
|
||||
case "<":
|
||||
return left.Before(right)
|
||||
case ">=":
|
||||
return left.After(right) || left.Equal(right)
|
||||
case "<=":
|
||||
return left.Before(right) || left.Equal(right)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareDurations(left time.Duration, op string, right time.Duration) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return left == right
|
||||
case "!=":
|
||||
return left != right
|
||||
case ">":
|
||||
return left > right
|
||||
case "<":
|
||||
return left < right
|
||||
case ">=":
|
||||
return left >= right
|
||||
case "<=":
|
||||
return left <= right
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TestDoubleNegation tests that NOT NOT works correctly
|
||||
func TestDoubleNegation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "double negation - should match",
|
||||
expr: "NOT NOT status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true, // NOT NOT true = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "double negation - should not match",
|
||||
expr: "NOT NOT status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: false, // NOT NOT false = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "double negation with parentheses",
|
||||
expr: "NOT (NOT (status = 'ready'))",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple negation - odd number",
|
||||
expr: "NOT NOT NOT status = 'done'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true, // NOT NOT NOT false = NOT NOT true = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "triple negation - cancels to NOT",
|
||||
expr: "NOT NOT NOT status = 'done'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: false, // NOT NOT NOT true = NOT NOT false = NOT true = false
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyFilter tests handling of empty filter expressions
|
||||
func TestEmptyFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
expect bool // empty filter should match all tasks
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
expr: "",
|
||||
expect: true, // nil filter means no filtering
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
expr: " ",
|
||||
expect: true, // trimmed to empty, should be nil filter
|
||||
},
|
||||
}
|
||||
|
||||
task := &task.Task{Status: task.StatusReady}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
// Empty filter returns nil
|
||||
if filter == nil {
|
||||
// Nil filter means match all - this is correct behavior
|
||||
return
|
||||
}
|
||||
|
||||
result := filter.Evaluate(task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %q", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplexNOTExpressions tests NOT with various complex expressions
|
||||
func TestComplexNOTExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "NOT with AND - both conditions true",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: false, // NOT (true AND true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with AND - one condition false",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // NOT (true AND false) = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "NOT with OR - both conditions true",
|
||||
expr: "NOT (status = 'ready' OR type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: false, // NOT (true OR true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with OR - one condition true",
|
||||
expr: "NOT (status = 'ready' OR type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false, // NOT (true OR false) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with OR - both conditions false",
|
||||
expr: "NOT (status = 'ready' OR type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
|
||||
expect: true, // NOT (false OR false) = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "NOT with complex mixed expression",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: false, // NOT ((true AND true) OR false) = NOT (true OR false) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with complex mixed expression - alternative match",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory},
|
||||
expect: false, // NOT ((false AND false) OR true) = NOT (false OR true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT with complex mixed expression - no match",
|
||||
expr: "NOT (status = 'ready' AND type = 'bug' OR status = 'inProgress')",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
|
||||
expect: true, // NOT ((false AND false) OR false) = NOT false = true
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllOperatorsCombined tests expressions using all available operators
|
||||
func TestAllOperatorsCombined(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "all operators - all conditions match",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Tags: []string{"active"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "all operators - NOT fails",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 5, Tags: []string{"active"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "all operators - IN fails",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: "epic", Priority: 2, Tags: []string{"active"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "all operators - NOT IN fails",
|
||||
expr: "NOT status = 'done' AND (type IN ['bug', 'story'] OR priority > 3) AND tags NOT IN ['deprecated']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Tags: []string{"deprecated"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "complex with comparisons and IN",
|
||||
expr: "(priority >= 3 AND priority <= 5) OR (status IN ['ready', 'inProgress'] AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
|
||||
expect: true, // Second part matches
|
||||
},
|
||||
{
|
||||
name: "complex with multiple NOT",
|
||||
expr: "NOT status = 'done' AND NOT type = 'epic' AND NOT tags IN ['deprecated', 'archived']",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Tags: []string{"active"}},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVeryLongExpressionChains tests that parser handles long chains correctly
|
||||
func TestVeryLongExpressionChains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "five AND chain",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 2 AND priority < 6 AND points > 0",
|
||||
task: &task.Task{
|
||||
Status: task.StatusReady,
|
||||
Type: task.TypeBug,
|
||||
Priority: 4,
|
||||
Points: 3,
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "five OR chain - last matches",
|
||||
expr: "status = 'done' OR status = 'cancelled' OR status = 'inProgress' OR status = 'review' OR status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "alternating AND/OR chain",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3 OR points > 10 AND status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2, Points: 5},
|
||||
expect: true, // First OR condition matches
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComparisonOperators tests all comparison operators comprehensively
|
||||
func TestComparisonOperators(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// Equality
|
||||
{
|
||||
name: "equality with =",
|
||||
expr: "priority = 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "equality with ==",
|
||||
expr: "priority == 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
|
||||
// Inequality
|
||||
{
|
||||
name: "inequality - not equal",
|
||||
expr: "priority != 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "inequality - equal",
|
||||
expr: "priority != 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Greater than
|
||||
{
|
||||
name: "greater than - true",
|
||||
expr: "priority > 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "greater than - false equal",
|
||||
expr: "priority > 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "greater than - false less",
|
||||
expr: "priority > 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Less than
|
||||
{
|
||||
name: "less than - true",
|
||||
expr: "priority < 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "less than - false equal",
|
||||
expr: "priority < 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "less than - false greater",
|
||||
expr: "priority < 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Greater than or equal
|
||||
{
|
||||
name: "greater or equal - greater",
|
||||
expr: "priority >= 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "greater or equal - equal",
|
||||
expr: "priority >= 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "greater or equal - less",
|
||||
expr: "priority >= 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Less than or equal
|
||||
{
|
||||
name: "less or equal - less",
|
||||
expr: "priority <= 3",
|
||||
task: &task.Task{Priority: 1},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "less or equal - equal",
|
||||
expr: "priority <= 3",
|
||||
task: &task.Task{Priority: 3},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "less or equal - greater",
|
||||
expr: "priority <= 3",
|
||||
task: &task.Task{Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// String comparisons (lexicographic)
|
||||
{
|
||||
name: "string greater than",
|
||||
expr: "status > 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true, // "ready" > "inProgress" lexicographically
|
||||
},
|
||||
{
|
||||
name: "string less than",
|
||||
expr: "status < 'ready'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: true, // "done" < "ready" lexicographically
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s (priority=%d, status=%s)",
|
||||
tt.expect, result, tt.expr, tt.task.Priority, tt.task.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealWorldScenarios tests realistic filter expressions users might write
|
||||
func TestRealWorldScenarios(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "recent high-priority bugs",
|
||||
expr: "type = 'bug' AND priority >= 4 AND NOW - CreatedAt < 7days",
|
||||
task: &task.Task{Type: task.TypeBug, Priority: 5, CreatedAt: now.Add(-3 * 24 * time.Hour)},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "stale tasks needing attention",
|
||||
expr: "status IN ['ready', 'inProgress', 'inProgress'] AND NOW - UpdatedAt > 14days",
|
||||
task: &task.Task{Status: task.StatusReady, UpdatedAt: now.Add(-20 * 24 * time.Hour)},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "UI/UX work in progress",
|
||||
expr: "tags IN ['ui', 'ux', 'design'] AND status IN ['ready', 'inProgress'] AND type != 'epic'",
|
||||
task: &task.Task{Tags: []string{"ui", "frontend"}, Status: task.StatusInProgress, Type: task.TypeStory},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "ready for release",
|
||||
expr: "status = 'done' AND type IN ['story', 'bug'] AND tags NOT IN ['not-deployable', 'experimental']",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Tags: []string{"ready"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "blocked high-value items",
|
||||
expr: "status = 'inProgress' AND (priority >= 4 OR points >= 8)",
|
||||
task: &task.Task{Status: task.StatusInProgress, Priority: 2, Points: 10},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, now, "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseFilter parses a filter expression string into an AST.
|
||||
// This is the main public entry point for filter parsing.
|
||||
//
|
||||
// Example expressions:
|
||||
// - status = 'done'
|
||||
// - type = 'bug' AND priority > 2
|
||||
// - status IN ['ready', 'in_progress']
|
||||
// - NOW - CreatedAt < 24hour
|
||||
// - (status = 'ready' OR status = 'in_progress') AND priority >= 3
|
||||
//
|
||||
// Returns nil expression for empty string (no filtering).
|
||||
func ParseFilter(expr string) (FilterExpr, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil, nil // empty filter = no filtering (all tasks pass)
|
||||
}
|
||||
|
||||
tokens, err := Tokenize(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parser := newFilterParser(tokens)
|
||||
result, err := parser.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure we consumed all tokens
|
||||
if parser.pos < len(parser.tokens) && parser.tokens[parser.pos].Type != TokenEOF {
|
||||
return nil, fmt.Errorf("unexpected token at position %d: %s", parser.pos, parser.tokens[parser.pos].Value)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestParseFilterWithIn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "tags IN with match",
|
||||
expr: "tags IN ['ui', 'charts']",
|
||||
task: &task.Task{Tags: []string{"ui", "backend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "tags IN with no match",
|
||||
expr: "tags IN ['frontend', 'api']",
|
||||
task: &task.Task{Tags: []string{"ui", "backend"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "tags IN with single value match",
|
||||
expr: "tags IN ['ui']",
|
||||
task: &task.Task{Tags: []string{"ui", "backend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "tags IN empty list",
|
||||
expr: "tags IN []",
|
||||
task: &task.Task{Tags: []string{"ui"}},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "status IN with match",
|
||||
expr: "status IN ['ready', 'inProgress']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "status IN with no match",
|
||||
expr: "status IN ['done', 'cancelled']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "status NOT IN with match",
|
||||
expr: "status NOT IN ['done', 'cancelled']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "status NOT IN with no match",
|
||||
expr: "status NOT IN ['ready', 'inProgress']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "type IN with match",
|
||||
expr: "type IN ['story', 'bug']",
|
||||
task: &task.Task{Type: task.TypeStory},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "priority IN with integers",
|
||||
expr: "priority IN [1, 2, 3]",
|
||||
task: &task.Task{Priority: 2},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "priority IN with no match",
|
||||
expr: "priority IN [1, 3, 5]",
|
||||
task: &task.Task{Priority: 2},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "combined with AND",
|
||||
expr: "tags IN ['ui', 'charts'] AND status = 'ready'",
|
||||
task: &task.Task{Tags: []string{"ui"}, Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "combined with AND, no match",
|
||||
expr: "tags IN ['ui', 'charts'] AND status = 'done'",
|
||||
task: &task.Task{Tags: []string{"ui"}, Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "combined with OR",
|
||||
expr: "tags IN ['ui'] OR tags IN ['backend']",
|
||||
task: &task.Task{Tags: []string{"backend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive tags",
|
||||
expr: "tags IN ['UI', 'Charts']",
|
||||
task: &task.Task{Tags: []string{"ui", "charts"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive status",
|
||||
expr: "status IN ['READY', 'IN_PROGRESS']",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "snake_case filter matches camelCase task status",
|
||||
expr: "status = 'in_progress'",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "snake_case IN filter matches camelCase task status",
|
||||
expr: "status IN ['in_progress', 'review']",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "NOT with IN expression",
|
||||
expr: "NOT (tags IN ['deprecated', 'archived'])",
|
||||
task: &task.Task{Tags: []string{"ui", "active"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "complex expression",
|
||||
expr: "(tags IN ['ui', 'frontend'] OR type = 'bug') AND status NOT IN ['done']",
|
||||
task: &task.Task{Tags: []string{"ui"}, Type: task.TypeStory, Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenizeWithIn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []TokenType
|
||||
}{
|
||||
{
|
||||
name: "simple IN expression",
|
||||
input: "tags IN ['ui']",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
|
||||
TokenString, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "NOT IN expression",
|
||||
input: "status NOT IN ['done']",
|
||||
expected: []TokenType{TokenIdent, TokenNotIn, TokenLBracket,
|
||||
TokenString, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "multiple values with commas",
|
||||
input: "type IN ['bug', 'story', 'epic']",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
|
||||
TokenString, TokenComma, TokenString,
|
||||
TokenComma, TokenString, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "IN with numbers",
|
||||
input: "priority IN [1, 2, 3]",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket,
|
||||
TokenNumber, TokenComma, TokenNumber,
|
||||
TokenComma, TokenNumber, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
input: "tags IN []",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket, TokenRBracket, TokenEOF},
|
||||
},
|
||||
{
|
||||
name: "IN with AND",
|
||||
input: "tags IN ['ui'] AND status = 'ready'",
|
||||
expected: []TokenType{TokenIdent, TokenIn, TokenLBracket, TokenString, TokenRBracket,
|
||||
TokenAnd, TokenIdent, TokenOperator, TokenString, TokenEOF},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokens, err := Tokenize(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("tokenize failed: %v", err)
|
||||
}
|
||||
|
||||
if len(tokens) != len(tt.expected) {
|
||||
t.Fatalf("Expected %d tokens, got %d", len(tt.expected), len(tokens))
|
||||
}
|
||||
|
||||
for i, tok := range tokens {
|
||||
if tok.Type != tt.expected[i] {
|
||||
t.Errorf("Token %d: expected type %d, got %d (value: %s)",
|
||||
i, tt.expected[i], tok.Type, tok.Value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFilterErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing opening bracket",
|
||||
expr: "tags IN 'ui', 'charts']",
|
||||
errMsg: "expected '['",
|
||||
},
|
||||
{
|
||||
name: "missing closing bracket",
|
||||
expr: "tags IN ['ui', 'charts'",
|
||||
errMsg: "expected ','",
|
||||
},
|
||||
{
|
||||
name: "missing comma",
|
||||
expr: "tags IN ['ui' 'charts']",
|
||||
errMsg: "expected ','",
|
||||
},
|
||||
{
|
||||
name: "invalid value in list",
|
||||
expr: "tags IN ['ui', =]",
|
||||
errMsg: "unexpected token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseFilter(tt.expr)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
// Could check error message contains expected substring if needed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInExprWithCurrentUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
currentUser string
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "assignee IN with CURRENT_USER match",
|
||||
expr: "assignee IN ['alice', CURRENT_USER, 'bob']",
|
||||
task: &task.Task{Assignee: "testuser"},
|
||||
currentUser: "testuser",
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "assignee IN with CURRENT_USER no match",
|
||||
expr: "assignee IN ['alice', CURRENT_USER, 'bob']",
|
||||
task: &task.Task{Assignee: "charlie"},
|
||||
currentUser: "testuser",
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "assignee IN with only CURRENT_USER",
|
||||
expr: "assignee IN [CURRENT_USER]",
|
||||
task: &task.Task{Assignee: "testuser"},
|
||||
currentUser: "testuser",
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), tt.currentUser)
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v", tt.expect, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInExprBackwardCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "old style tag comparison",
|
||||
expr: "tag = 'ui'",
|
||||
task: &task.Task{Tags: []string{"ui", "frontend"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "old style OR chain",
|
||||
expr: "(tag = 'ui' OR tag = 'charts' OR tag = 'viz')",
|
||||
task: &task.Task{Tags: []string{"charts"}},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "status comparison",
|
||||
expr: "status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "complex old style expression",
|
||||
expr: "status = 'ready' AND (tag = 'ui' OR tag = 'backend')",
|
||||
task: &task.Task{Status: task.StatusReady, Tags: []string{"ui"}},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v", tt.expect, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TestOperatorPrecedence tests that operators follow correct precedence: NOT > AND > OR
|
||||
func TestOperatorPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// NOT has highest precedence
|
||||
{
|
||||
name: "NOT before AND",
|
||||
expr: "NOT status = 'done' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: true, // (NOT (status = 'done')) AND (type = 'bug') = true AND true = true
|
||||
},
|
||||
{
|
||||
name: "NOT before AND - false case",
|
||||
expr: "NOT status = 'done' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false, // (NOT (status = 'done')) AND (type = 'bug') = true AND false = false
|
||||
},
|
||||
{
|
||||
name: "NOT before AND - with done status",
|
||||
expr: "NOT status = 'done' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
|
||||
expect: false, // (NOT (status = 'done')) AND (type = 'bug') = false AND true = false
|
||||
},
|
||||
|
||||
// AND before OR - left side
|
||||
{
|
||||
name: "AND before OR - left match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "AND before OR - right match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "AND before OR - no match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory},
|
||||
expect: false, // status = 'ready' OR (status = 'inProgress' AND type = 'bug') = false OR false = false
|
||||
},
|
||||
|
||||
// AND before OR - right side
|
||||
{
|
||||
name: "AND before OR - right side left match",
|
||||
expr: "status = 'inProgress' AND type = 'bug' OR status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // (status = 'inProgress' AND type = 'bug') OR status = 'ready' = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "AND before OR - right side right match",
|
||||
expr: "status = 'inProgress' AND type = 'bug' OR status = 'ready'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (status = 'inProgress' AND type = 'bug') OR status = 'ready' = true OR false = true
|
||||
},
|
||||
|
||||
// Parentheses override precedence
|
||||
{
|
||||
name: "parentheses override AND/OR precedence - no match",
|
||||
expr: "(status = 'ready' OR status = 'inProgress') AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false, // (status = 'ready' OR status = 'inProgress') AND type = 'bug' = true AND false = false
|
||||
},
|
||||
{
|
||||
name: "parentheses override AND/OR precedence - match",
|
||||
expr: "(status = 'ready' OR status = 'inProgress') AND type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (status = 'ready' OR status = 'inProgress') AND type = 'bug' = true AND true = true
|
||||
},
|
||||
|
||||
// NOT with OR
|
||||
{
|
||||
name: "NOT before OR",
|
||||
expr: "NOT status = 'done' OR type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory},
|
||||
expect: false, // (NOT (status = 'done')) OR (type = 'bug') = false OR false = false
|
||||
},
|
||||
{
|
||||
name: "NOT before OR - match on NOT",
|
||||
expr: "NOT status = 'done' OR type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: true, // (NOT (status = 'done')) OR (type = 'bug') = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "NOT before OR - match on type",
|
||||
expr: "NOT status = 'done' OR type = 'bug'",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
|
||||
expect: true, // (NOT (status = 'done')) OR (type = 'bug') = false OR true = true
|
||||
},
|
||||
|
||||
// Complex precedence: NOT > AND > OR
|
||||
{
|
||||
name: "NOT > AND > OR - all operators",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5},
|
||||
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = true OR true = true
|
||||
},
|
||||
{
|
||||
name: "NOT > AND > OR - match on OR only",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
|
||||
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "NOT > AND > OR - match on AND only",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
|
||||
expect: true, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "NOT > AND > OR - no match",
|
||||
expr: "NOT status = 'done' AND type = 'bug' OR priority > 3",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Priority: 1},
|
||||
expect: false, // ((NOT (status = 'done')) AND type = 'bug') OR priority > 3 = false OR false = false
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s\nTask: status=%s, type=%s, priority=%d",
|
||||
tt.expect, result, tt.expr, tt.task.Status, tt.task.Type, tt.task.Priority)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestedParentheses tests deeply nested parentheses expressions
|
||||
func TestNestedParentheses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// Double nesting
|
||||
{
|
||||
name: "double nested parentheses - match",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "double nested parentheses - no match on type",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "double nested parentheses - no match on status",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND type = 'bug')",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Triple nesting with NOT
|
||||
{
|
||||
name: "triple nested with NOT - match",
|
||||
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
|
||||
task: &task.Task{Status: task.StatusReady, Priority: 2},
|
||||
expect: true, // NOT ((false OR false) AND true) = NOT (false AND true) = NOT false = true
|
||||
},
|
||||
{
|
||||
name: "triple nested with NOT - no match",
|
||||
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
|
||||
task: &task.Task{Status: task.StatusDone, Priority: 2},
|
||||
expect: false, // NOT ((true OR false) AND true) = NOT (true AND true) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "triple nested with NOT - no match on priority",
|
||||
expr: "NOT (((status = 'done' OR status = 'cancelled') AND priority < 3))",
|
||||
task: &task.Task{Status: task.StatusDone, Priority: 5},
|
||||
expect: true, // NOT ((true OR false) AND false) = NOT (true AND false) = NOT false = true
|
||||
},
|
||||
|
||||
// Mixed nesting depth
|
||||
{
|
||||
name: "mixed nesting depth - OR at end",
|
||||
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (false AND (true OR false)) OR true = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "mixed nesting depth - match on nested OR",
|
||||
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug},
|
||||
expect: true, // (true AND (true OR false)) OR false = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "mixed nesting depth - match on final OR",
|
||||
expr: "(status = 'ready' AND (type = 'bug' OR type = 'story')) OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug},
|
||||
expect: true, // (false AND (true OR false)) OR true = false OR true = true
|
||||
},
|
||||
|
||||
// Complex nested with multiple operations
|
||||
{
|
||||
name: "complex nested - all conditions",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
|
||||
expect: true, // (true OR false) AND (false OR true) = true AND true = true
|
||||
},
|
||||
{
|
||||
name: "complex nested - left fails",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeStory, Priority: 5},
|
||||
expect: false, // (false OR false) AND (false OR true) = false AND true = false
|
||||
},
|
||||
{
|
||||
name: "complex nested - right fails",
|
||||
expr: "((status = 'ready' OR status = 'inProgress') AND (type = 'bug' OR priority > 3))",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2},
|
||||
expect: false, // (true OR false) AND (false OR false) = true AND false = false
|
||||
},
|
||||
|
||||
// Nested with NOT at different levels
|
||||
{
|
||||
name: "NOT outside nested expression",
|
||||
expr: "NOT ((status = 'done' AND type = 'bug') OR priority < 2)",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 3},
|
||||
expect: false, // NOT ((true AND true) OR false) = NOT (true OR false) = NOT true = false
|
||||
},
|
||||
{
|
||||
name: "NOT inside nested expression",
|
||||
expr: "(NOT status = 'done' AND type = 'bug') OR priority > 5",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 3},
|
||||
expect: true, // ((NOT false) AND true) OR false = (true AND true) OR false = true
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplexBooleanChains tests multiple operators chained together
|
||||
func TestComplexBooleanChains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// Triple AND chain
|
||||
{
|
||||
name: "triple AND chain - all match",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple AND chain - first fails",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusDone, Type: task.TypeBug, Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "triple AND chain - middle fails",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 5},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "triple AND chain - last fails",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 2},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Triple OR chain
|
||||
{
|
||||
name: "triple OR chain - first matches",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple OR chain - middle matches",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple OR chain - last matches",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusInProgress},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "triple OR chain - none match",
|
||||
expr: "status = 'ready' OR status = 'inProgress' OR status = 'inProgress'",
|
||||
task: &task.Task{Status: task.StatusDone},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Mixed chain without parentheses - tests precedence
|
||||
{
|
||||
name: "mixed chain A OR B AND C - match on A",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeStory, Priority: 2},
|
||||
expect: true, // status = 'ready' OR (type = 'bug' AND priority > 3) = true OR false = true
|
||||
},
|
||||
{
|
||||
name: "mixed chain A OR B AND C - match on B AND C",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeBug, Priority: 5},
|
||||
expect: true, // status = 'ready' OR (type = 'bug' AND priority > 3) = false OR true = true
|
||||
},
|
||||
{
|
||||
name: "mixed chain A OR B AND C - no match",
|
||||
expr: "status = 'ready' OR type = 'bug' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory, Priority: 2},
|
||||
expect: false, // status = 'ready' OR (type = 'bug' AND priority > 3) = false OR false = false
|
||||
},
|
||||
|
||||
// Longer chains
|
||||
{
|
||||
name: "four operator chain - AND heavy",
|
||||
expr: "status = 'ready' AND type = 'bug' AND priority > 3 AND points < 10",
|
||||
task: &task.Task{Status: task.StatusReady, Type: task.TypeBug, Priority: 5, Points: 8},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "four operator chain - mixed AND/OR",
|
||||
expr: "status = 'ready' AND type = 'bug' OR status = 'inProgress' AND priority > 3",
|
||||
task: &task.Task{Status: task.StatusInProgress, Type: task.TypeStory, Priority: 5},
|
||||
expect: true, // (status = 'ready' AND type = 'bug') OR (status = 'inProgress' AND priority > 3) = false OR true = true
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, time.Now(), "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TestTimeExpressions tests time-based filter expressions like "NOW - CreatedAt < 24hour"
|
||||
func TestTimeExpressions(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
// NOW - UpdatedAt comparisons
|
||||
{
|
||||
name: "recent task - under 2 hours",
|
||||
expr: "NOW - UpdatedAt < 2hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
expect: true, // Updated 1 hour ago, less than 2 hours
|
||||
},
|
||||
{
|
||||
name: "old task - over 2 hours",
|
||||
expr: "NOW - UpdatedAt < 2hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-3 * time.Hour)},
|
||||
expect: false, // Updated 3 hours ago, more than 2 hours
|
||||
},
|
||||
{
|
||||
name: "exact boundary - 2 hours",
|
||||
expr: "NOW - UpdatedAt < 2hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-2 * time.Hour)},
|
||||
expect: false, // Updated exactly 2 hours ago, not less than 2 hours
|
||||
},
|
||||
|
||||
// Greater than comparisons
|
||||
{
|
||||
name: "old task - over 1 week",
|
||||
expr: "NOW - UpdatedAt > 1week",
|
||||
task: &task.Task{UpdatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
expect: true, // Updated 10 days ago, more than 1 week
|
||||
},
|
||||
{
|
||||
name: "recent task - under 1 week",
|
||||
expr: "NOW - UpdatedAt > 1week",
|
||||
task: &task.Task{UpdatedAt: now.Add(-3 * 24 * time.Hour)},
|
||||
expect: false, // Updated 3 days ago, less than 1 week
|
||||
},
|
||||
|
||||
// NOW - CreatedAt comparisons
|
||||
{
|
||||
name: "recently created - under 1 month",
|
||||
expr: "NOW - CreatedAt < 1month",
|
||||
task: &task.Task{CreatedAt: now.Add(-15 * 24 * time.Hour)},
|
||||
expect: true, // Created 15 days ago, less than 30 days (1 month)
|
||||
},
|
||||
{
|
||||
name: "old creation - over 1 month",
|
||||
expr: "NOW - CreatedAt < 1month",
|
||||
task: &task.Task{CreatedAt: now.Add(-40 * 24 * time.Hour)},
|
||||
expect: false, // Created 40 days ago, more than 30 days
|
||||
},
|
||||
|
||||
// Less than or equal comparisons
|
||||
{
|
||||
name: "task age <= 24 hours - exact match",
|
||||
expr: "NOW - UpdatedAt <= 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-24 * time.Hour)},
|
||||
expect: true, // Updated exactly 24 hours ago
|
||||
},
|
||||
{
|
||||
name: "task age <= 24 hours - under",
|
||||
expr: "NOW - UpdatedAt <= 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour)},
|
||||
expect: true, // Updated 12 hours ago
|
||||
},
|
||||
{
|
||||
name: "task age <= 24 hours - over",
|
||||
expr: "NOW - UpdatedAt <= 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Hour)},
|
||||
expect: false, // Updated 30 hours ago
|
||||
},
|
||||
|
||||
// Greater than or equal comparisons
|
||||
{
|
||||
name: "task age >= 1 day - exact match",
|
||||
expr: "NOW - CreatedAt >= 1day",
|
||||
task: &task.Task{CreatedAt: now.Add(-24 * time.Hour)},
|
||||
expect: true, // Created exactly 1 day ago
|
||||
},
|
||||
{
|
||||
name: "task age >= 1 day - over",
|
||||
expr: "NOW - CreatedAt >= 1day",
|
||||
task: &task.Task{CreatedAt: now.Add(-48 * time.Hour)},
|
||||
expect: true, // Created 2 days ago
|
||||
},
|
||||
{
|
||||
name: "task age >= 1 day - under",
|
||||
expr: "NOW - CreatedAt >= 1day",
|
||||
task: &task.Task{CreatedAt: now.Add(-12 * time.Hour)},
|
||||
expect: false, // Created 12 hours ago
|
||||
},
|
||||
|
||||
// Different duration units
|
||||
{
|
||||
name: "minutes - under threshold",
|
||||
expr: "NOW - UpdatedAt < 60min",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Minute)},
|
||||
expect: true, // Updated 30 minutes ago
|
||||
},
|
||||
{
|
||||
name: "days - over threshold",
|
||||
expr: "NOW - UpdatedAt > 7days",
|
||||
task: &task.Task{UpdatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
expect: true, // Updated 10 days ago
|
||||
},
|
||||
{
|
||||
name: "weeks - under threshold",
|
||||
expr: "NOW - CreatedAt < 2weeks",
|
||||
task: &task.Task{CreatedAt: now.Add(-10 * 24 * time.Hour)},
|
||||
expect: true, // Created 10 days ago, less than 14 days
|
||||
},
|
||||
|
||||
// Combined with other conditions
|
||||
{
|
||||
name: "time condition AND status",
|
||||
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour), Status: task.StatusReady},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "time condition AND status - status mismatch",
|
||||
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour), Status: task.StatusDone},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "time condition AND status - time mismatch",
|
||||
expr: "NOW - UpdatedAt < 24hours AND status = 'ready'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-48 * time.Hour), Status: task.StatusReady},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// Time condition OR other conditions
|
||||
{
|
||||
name: "time condition OR type - time matches",
|
||||
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Minute), Type: task.TypeStory},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "time condition OR type - type matches",
|
||||
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Hour), Type: task.TypeBug},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "time condition OR type - neither matches",
|
||||
expr: "NOW - UpdatedAt < 1hour OR type = 'bug'",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Hour), Type: task.TypeStory},
|
||||
expect: false,
|
||||
},
|
||||
|
||||
// NOT with time conditions
|
||||
{
|
||||
name: "NOT time condition - should match",
|
||||
expr: "NOT (NOW - UpdatedAt < 24hours)",
|
||||
task: &task.Task{UpdatedAt: now.Add(-48 * time.Hour)},
|
||||
expect: true, // Updated 48 hours ago, NOT less than 24 hours
|
||||
},
|
||||
{
|
||||
name: "NOT time condition - should not match",
|
||||
expr: "NOT (NOW - UpdatedAt < 24hours)",
|
||||
task: &task.Task{UpdatedAt: now.Add(-12 * time.Hour)},
|
||||
expect: false, // Updated 12 hours ago, NOT (less than 24 hours) = false
|
||||
},
|
||||
|
||||
// Equality (rarely useful but should work)
|
||||
{
|
||||
name: "time equality - not equal",
|
||||
expr: "NOW - UpdatedAt = 24hours",
|
||||
task: &task.Task{UpdatedAt: now.Add(-25 * time.Hour)},
|
||||
expect: false, // Small timing differences make exact equality unlikely
|
||||
},
|
||||
|
||||
// Inequality
|
||||
{
|
||||
name: "time inequality - not equal",
|
||||
expr: "NOW - UpdatedAt != 0min",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Minute)},
|
||||
expect: true, // Updated 5 minutes ago, not equal to 0
|
||||
},
|
||||
|
||||
// Edge case: very recent update (near zero duration)
|
||||
{
|
||||
name: "very recent update",
|
||||
expr: "NOW - UpdatedAt < 1min",
|
||||
task: &task.Task{UpdatedAt: now.Add(-5 * time.Second)},
|
||||
expect: true, // Updated 5 seconds ago
|
||||
},
|
||||
|
||||
// seconds unit
|
||||
{
|
||||
name: "seconds - under threshold",
|
||||
expr: "NOW - UpdatedAt < 30secs",
|
||||
task: &task.Task{UpdatedAt: now.Add(-10 * time.Second)},
|
||||
expect: true, // Updated 10 seconds ago
|
||||
},
|
||||
{
|
||||
name: "seconds - over threshold",
|
||||
expr: "NOW - UpdatedAt < 10sec",
|
||||
task: &task.Task{UpdatedAt: now.Add(-30 * time.Second)},
|
||||
expect: false, // Updated 30 seconds ago
|
||||
},
|
||||
|
||||
// years unit
|
||||
{
|
||||
name: "years - under threshold",
|
||||
expr: "NOW - CreatedAt < 1year",
|
||||
task: &task.Task{CreatedAt: now.Add(-200 * 24 * time.Hour)},
|
||||
expect: true, // Created 200 days ago, less than 365 days
|
||||
},
|
||||
{
|
||||
name: "years - over threshold",
|
||||
expr: "NOW - CreatedAt > 1year",
|
||||
task: &task.Task{CreatedAt: now.Add(-400 * 24 * time.Hour)},
|
||||
expect: true, // Created 400 days ago, more than 365 days
|
||||
},
|
||||
|
||||
// Edge case: future time (shouldn't normally happen, but test negative duration)
|
||||
{
|
||||
name: "future time - negative duration",
|
||||
expr: "NOW - UpdatedAt < 1hour",
|
||||
task: &task.Task{UpdatedAt: now.Add(1 * time.Hour)},
|
||||
expect: true, // Future time results in negative duration, which is < 1hour
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, now, "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s\nTask UpdatedAt: %v, CreatedAt: %v",
|
||||
tt.expect, result, tt.expr, tt.task.UpdatedAt, tt.task.CreatedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeExpressionParsing tests that time expressions parse correctly
|
||||
func TestTimeExpressionParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid NOW - UpdatedAt",
|
||||
expr: "NOW - UpdatedAt < 24hours",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid NOW - CreatedAt",
|
||||
expr: "NOW - CreatedAt > 1week",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with minutes",
|
||||
expr: "NOW - UpdatedAt < 30min",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with days",
|
||||
expr: "NOW - CreatedAt >= 7days",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with months",
|
||||
expr: "NOW - UpdatedAt < 2months",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with seconds",
|
||||
expr: "NOW - UpdatedAt < 30secs",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with years",
|
||||
expr: "NOW - CreatedAt > 1year",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid with parentheses",
|
||||
expr: "(NOW - UpdatedAt < 1hour)",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "valid combined with AND",
|
||||
expr: "NOW - UpdatedAt < 1day AND status = 'ready'",
|
||||
shouldError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if tt.shouldError && err == nil {
|
||||
t.Error("Expected parsing error but got none")
|
||||
}
|
||||
if !tt.shouldError && err != nil {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
if !tt.shouldError && filter == nil {
|
||||
t.Error("Expected filter but got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleTimeConditions tests filters with multiple time-based conditions
|
||||
func TestMultipleTimeConditions(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
task *task.Task
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "both time conditions true",
|
||||
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-12 * time.Hour),
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "first time condition false",
|
||||
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-3 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-12 * time.Hour),
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "second time condition false",
|
||||
expr: "NOW - CreatedAt > 7days AND NOW - UpdatedAt < 24hours",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-48 * time.Hour),
|
||||
},
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "time conditions with OR",
|
||||
expr: "NOW - UpdatedAt < 1hour OR NOW - CreatedAt < 1day",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-30 * time.Minute),
|
||||
},
|
||||
expect: true, // Updated recently
|
||||
},
|
||||
{
|
||||
name: "complex time expression with status",
|
||||
expr: "(NOW - UpdatedAt < 2hours OR NOW - CreatedAt < 1day) AND status = 'ready'",
|
||||
task: &task.Task{
|
||||
CreatedAt: now.Add(-10 * 24 * time.Hour),
|
||||
UpdatedAt: now.Add(-1 * time.Hour),
|
||||
Status: task.StatusReady,
|
||||
},
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filter, err := ParseFilter(tt.expr)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFilter failed: %v", err)
|
||||
}
|
||||
|
||||
result := filter.Evaluate(tt.task, now, "testuser")
|
||||
if result != tt.expect {
|
||||
t.Errorf("Expected %v, got %v for expression: %s", tt.expect, result, tt.expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_ParseError(t *testing.T) {
|
||||
_, err := ParseDuration("abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_UnknownUnit(t *testing.T) {
|
||||
_, err := ParseDuration("10xyz")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown duration unit")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/parsing"
|
||||
)
|
||||
|
||||
// TokenType represents the type of a lexer token
|
||||
type TokenType int
|
||||
|
||||
const (
|
||||
TokenEOF TokenType = iota
|
||||
TokenIdent // field names like status, type, NOW, CURRENT_USER
|
||||
TokenString // 'value' or "value"
|
||||
TokenNumber // integer literals
|
||||
TokenDuration // 24hour, 1week, etc.
|
||||
TokenOperator // =, ==, !=, >, <, >=, <=, +, -
|
||||
TokenAnd
|
||||
TokenOr
|
||||
TokenNot
|
||||
TokenIn // IN keyword
|
||||
TokenNotIn // NOT IN keyword combination
|
||||
TokenLParen
|
||||
TokenRParen
|
||||
TokenLBracket // [ for list literals
|
||||
TokenRBracket // ] for list literals
|
||||
TokenComma // , for list elements
|
||||
)
|
||||
|
||||
// Token represents a lexer token
|
||||
type Token struct {
|
||||
Type TokenType
|
||||
Value string
|
||||
}
|
||||
|
||||
// Multi-character operators mapped to their token types
|
||||
var multiCharOps = map[string]TokenType{
|
||||
"==": TokenOperator,
|
||||
"!=": TokenOperator,
|
||||
">=": TokenOperator,
|
||||
"<=": TokenOperator,
|
||||
}
|
||||
|
||||
// Keywords mapped to their token types
|
||||
var keywords = map[string]TokenType{
|
||||
"AND": TokenAnd,
|
||||
"OR": TokenOr,
|
||||
"NOT": TokenNot,
|
||||
"IN": TokenIn,
|
||||
}
|
||||
|
||||
// Time field names (uppercase)
|
||||
var timeFields = map[string]bool{
|
||||
"NOW": true,
|
||||
"CREATEDAT": true,
|
||||
"UPDATEDAT": true,
|
||||
"DUE": true,
|
||||
}
|
||||
|
||||
// isTimeField checks if a given identifier is a time field (case-insensitive)
|
||||
func isTimeField(name string) bool {
|
||||
return timeFields[strings.ToUpper(name)]
|
||||
}
|
||||
|
||||
// Tokenize breaks the expression into tokens
|
||||
func Tokenize(expr string) ([]Token, error) {
|
||||
var tokens []Token
|
||||
i := 0
|
||||
|
||||
for i < len(expr) {
|
||||
// Skip whitespace
|
||||
i = parsing.SkipWhitespace(expr, i)
|
||||
if i >= len(expr) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check for two-character operators first
|
||||
if i+1 < len(expr) {
|
||||
twoChar := expr[i : i+2]
|
||||
if tokType, ok := multiCharOps[twoChar]; ok {
|
||||
tokens = append(tokens, Token{Type: tokType, Value: twoChar})
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Single character tokens
|
||||
switch expr[i] {
|
||||
case '(':
|
||||
tokens = append(tokens, Token{Type: TokenLParen, Value: "("})
|
||||
i++
|
||||
continue
|
||||
case ')':
|
||||
tokens = append(tokens, Token{Type: TokenRParen, Value: ")"})
|
||||
i++
|
||||
continue
|
||||
case '[':
|
||||
tokens = append(tokens, Token{Type: TokenLBracket, Value: "["})
|
||||
i++
|
||||
continue
|
||||
case ']':
|
||||
tokens = append(tokens, Token{Type: TokenRBracket, Value: "]"})
|
||||
i++
|
||||
continue
|
||||
case ',':
|
||||
tokens = append(tokens, Token{Type: TokenComma, Value: ","})
|
||||
i++
|
||||
continue
|
||||
case '=', '>', '<', '+', '-':
|
||||
tokens = append(tokens, Token{Type: TokenOperator, Value: string(expr[i])})
|
||||
i++
|
||||
continue
|
||||
case '\'', '"':
|
||||
// String literal
|
||||
quote := expr[i]
|
||||
i++
|
||||
start := i
|
||||
for i < len(expr) && expr[i] != quote {
|
||||
i++
|
||||
}
|
||||
if i >= len(expr) {
|
||||
return nil, fmt.Errorf("unterminated string literal")
|
||||
}
|
||||
tokens = append(tokens, Token{Type: TokenString, Value: expr[start:i]})
|
||||
i++ // skip closing quote
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for identifiers, keywords, numbers, and durations
|
||||
if unicode.IsLetter(rune(expr[i])) || expr[i] == '_' {
|
||||
word, newPos := parsing.ReadWhile(expr, i, func(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||
})
|
||||
i = newPos
|
||||
wordUpper := strings.ToUpper(word)
|
||||
|
||||
// Check for "NOT IN" keyword combination
|
||||
if wordUpper == "NOT" {
|
||||
if matched, endPos := parsing.PeekKeyword(expr, i, "IN"); matched {
|
||||
i = endPos
|
||||
tokens = append(tokens, Token{Type: TokenNotIn, Value: "NOT IN"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a keyword
|
||||
if tokType, ok := keywords[wordUpper]; ok {
|
||||
tokens = append(tokens, Token{Type: tokType, Value: word})
|
||||
} else {
|
||||
tokens = append(tokens, Token{Type: TokenIdent, Value: word})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for numbers (which might be followed by duration unit)
|
||||
if unicode.IsDigit(rune(expr[i])) {
|
||||
numStr, newPos := parsing.ReadWhile(expr, i, unicode.IsDigit)
|
||||
i = newPos
|
||||
|
||||
// Check if followed by duration unit
|
||||
if i < len(expr) && unicode.IsLetter(rune(expr[i])) {
|
||||
unitStr, unitEnd := parsing.ReadWhile(expr, i, unicode.IsLetter)
|
||||
fullWord := numStr + unitStr
|
||||
// Check if it's a valid duration
|
||||
if IsDurationLiteral(fullWord) {
|
||||
tokens = append(tokens, Token{Type: TokenDuration, Value: fullWord})
|
||||
i = unitEnd
|
||||
} else {
|
||||
// Not a valid duration, just a number
|
||||
tokens = append(tokens, Token{Type: TokenNumber, Value: numStr})
|
||||
}
|
||||
} else {
|
||||
tokens = append(tokens, Token{Type: TokenNumber, Value: numStr})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unexpected character at position %d: %c", i, expr[i])
|
||||
}
|
||||
|
||||
tokens = append(tokens, Token{Type: TokenEOF, Value: ""})
|
||||
return tokens, nil
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
package filter
|
||||
|
||||
import "fmt"
|
||||
|
||||
// filterParser is a recursive descent parser for filter expressions
|
||||
type filterParser struct {
|
||||
tokens []Token
|
||||
pos int
|
||||
}
|
||||
|
||||
// newFilterParser creates a new parser with the given tokens
|
||||
func newFilterParser(tokens []Token) *filterParser {
|
||||
return &filterParser{tokens: tokens, pos: 0}
|
||||
}
|
||||
|
||||
func (p *filterParser) current() Token {
|
||||
if p.pos >= len(p.tokens) {
|
||||
return Token{Type: TokenEOF}
|
||||
}
|
||||
return p.tokens[p.pos]
|
||||
}
|
||||
|
||||
func (p *filterParser) advance() {
|
||||
p.pos++
|
||||
}
|
||||
|
||||
func (p *filterParser) expect(t TokenType) error {
|
||||
tok := p.current()
|
||||
if tok.Type != t {
|
||||
return fmt.Errorf("expected token type %d, got %d (%s)", t, tok.Type, tok.Value)
|
||||
}
|
||||
p.advance()
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLeftAssociativeBinary parses left-associative binary operations
|
||||
// like "a AND b AND c" -> ((a AND b) AND c)
|
||||
func (p *filterParser) parseLeftAssociativeBinary(
|
||||
operatorType TokenType,
|
||||
operatorStr string,
|
||||
subExprParser func() (FilterExpr, error),
|
||||
) (FilterExpr, error) {
|
||||
left, err := subExprParser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for p.current().Type == operatorType {
|
||||
p.advance()
|
||||
right, err := subExprParser()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &BinaryExpr{Op: operatorStr, Left: left, Right: right}
|
||||
}
|
||||
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// parseExpr parses: expr = orExpr
|
||||
func (p *filterParser) parseExpr() (FilterExpr, error) {
|
||||
return p.parseOrExpr()
|
||||
}
|
||||
|
||||
// parseOrExpr parses: orExpr = andExpr (OR andExpr)*
|
||||
func (p *filterParser) parseOrExpr() (FilterExpr, error) {
|
||||
return p.parseLeftAssociativeBinary(TokenOr, "OR", p.parseAndExpr)
|
||||
}
|
||||
|
||||
// parseAndExpr parses: andExpr = notExpr (AND notExpr)*
|
||||
func (p *filterParser) parseAndExpr() (FilterExpr, error) {
|
||||
return p.parseLeftAssociativeBinary(TokenAnd, "AND", p.parseNotExpr)
|
||||
}
|
||||
|
||||
// parseNotExpr parses: notExpr = NOT notExpr | primaryExpr
|
||||
func (p *filterParser) parseNotExpr() (FilterExpr, error) {
|
||||
if p.current().Type == TokenNot {
|
||||
p.advance()
|
||||
expr, err := p.parseNotExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UnaryExpr{Op: "NOT", Expr: expr}, nil
|
||||
}
|
||||
return p.parsePrimaryExpr()
|
||||
}
|
||||
|
||||
// parsePrimaryExpr parses: primaryExpr = '(' expr ')' | inExpr | comparison
|
||||
func (p *filterParser) parsePrimaryExpr() (FilterExpr, error) {
|
||||
if p.current().Type == TokenLParen {
|
||||
p.advance()
|
||||
expr, err := p.parseExpr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.expect(TokenRParen); err != nil {
|
||||
return nil, fmt.Errorf("expected closing parenthesis: %w", err)
|
||||
}
|
||||
return expr, nil
|
||||
}
|
||||
|
||||
// Try to parse as IN expression or regular comparison
|
||||
// We need to look ahead to distinguish:
|
||||
// field IN [...] -> InExpr
|
||||
// field NOT IN [...] -> InExpr
|
||||
// field = value -> CompareExpr
|
||||
|
||||
// Check if this starts with an identifier (field name)
|
||||
if p.current().Type == TokenIdent {
|
||||
fieldName := p.current().Value
|
||||
p.advance()
|
||||
|
||||
// Check next token for IN or NOT IN
|
||||
nextTok := p.current()
|
||||
|
||||
if nextTok.Type == TokenIn {
|
||||
p.advance()
|
||||
return p.parseInExpr(fieldName, false)
|
||||
}
|
||||
if nextTok.Type == TokenNotIn {
|
||||
p.advance()
|
||||
return p.parseInExpr(fieldName, true)
|
||||
}
|
||||
|
||||
// Otherwise, backtrack and parse as regular comparison
|
||||
p.pos--
|
||||
return p.parseComparison()
|
||||
}
|
||||
|
||||
// Not an identifier, try parsing as comparison
|
||||
return p.parseComparison()
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package filter
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package filter
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValidateFilterStatuses walks a FilterExpr AST and checks that any status
|
||||
// references (in CompareExpr and InExpr nodes where Field == "status") are
|
||||
// valid according to the provided validator function.
|
||||
// Returns the first invalid status found with a descriptive error, or nil.
|
||||
func ValidateFilterStatuses(expr FilterExpr, validStatus func(string) bool) error {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch e := expr.(type) {
|
||||
case *BinaryExpr:
|
||||
if err := ValidateFilterStatuses(e.Left, validStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
return ValidateFilterStatuses(e.Right, validStatus)
|
||||
|
||||
case *UnaryExpr:
|
||||
return ValidateFilterStatuses(e.Expr, validStatus)
|
||||
|
||||
case *CompareExpr:
|
||||
if e.Field != "status" {
|
||||
return nil
|
||||
}
|
||||
if strVal, ok := e.Value.(string); ok {
|
||||
if !validStatus(strVal) {
|
||||
return fmt.Errorf("filter references unknown status %q", strVal)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case *InExpr:
|
||||
if e.Field != "status" {
|
||||
return nil
|
||||
}
|
||||
for _, val := range e.Values {
|
||||
if strVal, ok := val.(string); ok {
|
||||
if !validStatus(strVal) {
|
||||
return fmt.Errorf("filter references unknown status %q", strVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validStatus(key string) bool {
|
||||
valid := map[string]bool{
|
||||
"backlog": true, "ready": true, "inProgress": true, "review": true, "done": true,
|
||||
}
|
||||
return valid[key]
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_ValidCompare(t *testing.T) {
|
||||
expr := &CompareExpr{Field: "status", Op: "=", Value: "ready"}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_InvalidCompare(t *testing.T) {
|
||||
expr := &CompareExpr{Field: "status", Op: "=", Value: "bogus"}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status")
|
||||
}
|
||||
if got := err.Error(); got != `filter references unknown status "bogus"` {
|
||||
t.Errorf("unexpected error message: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_ValidInExpr(t *testing.T) {
|
||||
expr := &InExpr{Field: "status", Values: []interface{}{"ready", "done"}}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_InvalidInExpr(t *testing.T) {
|
||||
expr := &InExpr{Field: "status", Values: []interface{}{"ready", "unknown"}}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status in IN list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_NonStatusField(t *testing.T) {
|
||||
expr := &CompareExpr{Field: "type", Op: "=", Value: "anything"}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error for non-status field, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_BinaryExpr(t *testing.T) {
|
||||
expr := &BinaryExpr{
|
||||
Op: "AND",
|
||||
Left: &CompareExpr{Field: "status", Op: "=", Value: "ready"},
|
||||
Right: &CompareExpr{Field: "status", Op: "!=", Value: "bogus"},
|
||||
}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status in right branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_UnaryExpr(t *testing.T) {
|
||||
expr := &UnaryExpr{
|
||||
Op: "NOT",
|
||||
Expr: &CompareExpr{Field: "status", Op: "=", Value: "invalid"},
|
||||
}
|
||||
err := ValidateFilterStatuses(expr, validStatus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown status in NOT expr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_Nil(t *testing.T) {
|
||||
if err := ValidateFilterStatuses(nil, validStatus); err != nil {
|
||||
t.Errorf("expected no error for nil expr, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterStatuses_IntValue(t *testing.T) {
|
||||
// Status compared with int value — should be ignored (only string values checked)
|
||||
expr := &CompareExpr{Field: "status", Op: "=", Value: 42}
|
||||
if err := ValidateFilterStatuses(expr, validStatus); err != nil {
|
||||
t.Errorf("expected no error for non-string value, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,20 @@ package plugin
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestPluginWithInFilter(t *testing.T) {
|
||||
// Test loading a plugin definition with IN filter
|
||||
func newTestExecutor() *ruki.Executor {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
return ruki.NewExecutor(schema, func() string { return "testuser" },
|
||||
ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimePlugin})
|
||||
}
|
||||
|
||||
func TestPluginWithTagFilter(t *testing.T) {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pluginYAML := `
|
||||
name: UI Tasks
|
||||
foreground: "#ffffff"
|
||||
|
|
@ -16,136 +23,119 @@ background: "#0000ff"
|
|||
key: U
|
||||
lanes:
|
||||
- name: UI
|
||||
filter: tags IN ['ui', 'ux', 'design']
|
||||
filter: select where "ui" in tags or "ux" in tags or "design" in tags
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plugin: %v", err)
|
||||
t.Fatalf("failed to parse plugin: %v", err)
|
||||
}
|
||||
|
||||
if def.GetName() != "UI Tasks" {
|
||||
t.Errorf("Expected name 'UI Tasks', got '%s'", def.GetName())
|
||||
t.Errorf("expected name 'UI Tasks', got '%s'", def.GetName())
|
||||
}
|
||||
|
||||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
t.Fatalf("expected TikiPlugin, got %T", def)
|
||||
}
|
||||
|
||||
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
|
||||
t.Fatal("Expected lane filter to be parsed")
|
||||
t.Fatal("expected lane filter to be parsed")
|
||||
}
|
||||
|
||||
// Test filter evaluation with matching tasks
|
||||
matchingTask := &task.Task{
|
||||
ID: "TIKI-1",
|
||||
Title: "Design mockups",
|
||||
Tags: []string{"ui", "design"},
|
||||
Status: task.StatusReady,
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Title: "Design mockups", Tags: []string{"ui", "design"}, Status: task.StatusReady},
|
||||
{ID: "TIKI-000002", Title: "Backend API", Tags: []string{"backend", "api"}, Status: task.StatusReady},
|
||||
{ID: "TIKI-000003", Title: "UX Research", Tags: []string{"ux", "research"}, Status: task.StatusReady},
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match task with 'ui' and 'design' tags")
|
||||
executor := newTestExecutor()
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("executor error: %v", err)
|
||||
}
|
||||
|
||||
// Test filter evaluation with non-matching tasks
|
||||
nonMatchingTask := &task.Task{
|
||||
ID: "TIKI-2",
|
||||
Title: "Backend API",
|
||||
Tags: []string{"backend", "api"},
|
||||
Status: task.StatusReady,
|
||||
filtered := result.Select.Tasks
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("expected 2 matching tasks, got %d", len(filtered))
|
||||
}
|
||||
|
||||
if tp.Lanes[0].Filter.Evaluate(nonMatchingTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to NOT match task with 'backend' and 'api' tags")
|
||||
// task with "ui"+"design" and task with "ux" should match; "backend"+"api" should not
|
||||
ids := map[string]bool{}
|
||||
for _, tk := range filtered {
|
||||
ids[tk.ID] = true
|
||||
}
|
||||
|
||||
// Test with task that has one matching tag
|
||||
partialMatchTask := &task.Task{
|
||||
ID: "TIKI-3",
|
||||
Title: "UX Research",
|
||||
Tags: []string{"ux", "research"},
|
||||
Status: task.StatusReady,
|
||||
if !ids["TIKI-000001"] {
|
||||
t.Error("expected task TIKI-000001 (ui, design tags) to match")
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(partialMatchTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match task with 'ux' tag")
|
||||
if ids["TIKI-000002"] {
|
||||
t.Error("expected task TIKI-000002 (backend, api tags) to NOT match")
|
||||
}
|
||||
if !ids["TIKI-000003"] {
|
||||
t.Error("expected task TIKI-000003 (ux tag) to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWithComplexInFilter(t *testing.T) {
|
||||
// Test plugin with combined filters
|
||||
func TestPluginWithComplexTagAndStatusFilter(t *testing.T) {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pluginYAML := `
|
||||
name: Active Work
|
||||
key: A
|
||||
lanes:
|
||||
- name: Active
|
||||
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'backlog']
|
||||
filter: select where ("ui" in tags or "backend" in tags) and status != "done" and status != "backlog"
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plugin: %v", err)
|
||||
t.Fatalf("failed to parse plugin: %v", err)
|
||||
}
|
||||
|
||||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
t.Fatalf("expected TikiPlugin, got %T", def)
|
||||
}
|
||||
|
||||
// Should match: has 'ui' tag and status is 'ready' (not done)
|
||||
matchingTask := &task.Task{
|
||||
ID: "TIKI-1",
|
||||
Tags: []string{"ui", "frontend"},
|
||||
Status: task.StatusReady,
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Tags: []string{"ui", "frontend"}, Status: task.StatusReady},
|
||||
{ID: "TIKI-000002", Tags: []string{"ui"}, Status: task.StatusDone},
|
||||
{ID: "TIKI-000003", Tags: []string{"docs", "testing"}, Status: task.StatusInProgress},
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match active UI task")
|
||||
executor := newTestExecutor()
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("executor error: %v", err)
|
||||
}
|
||||
|
||||
// Should NOT match: has 'ui' tag but status is 'done'
|
||||
doneTask := &task.Task{
|
||||
ID: "TIKI-2",
|
||||
Tags: []string{"ui"},
|
||||
Status: task.StatusDone,
|
||||
filtered := result.Select.Tasks
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("expected 1 matching task, got %d", len(filtered))
|
||||
}
|
||||
|
||||
if tp.Lanes[0].Filter.Evaluate(doneTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to NOT match done UI task")
|
||||
}
|
||||
|
||||
// Should NOT match: status is active but no matching tags
|
||||
noTagsTask := &task.Task{
|
||||
ID: "TIKI-3",
|
||||
Tags: []string{"docs", "testing"},
|
||||
Status: task.StatusInProgress,
|
||||
}
|
||||
|
||||
if tp.Lanes[0].Filter.Evaluate(noTagsTask, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to NOT match task without matching tags")
|
||||
if filtered[0].ID != "TIKI-000001" {
|
||||
t.Errorf("expected TIKI-000001, got %s", filtered[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWithStatusInFilter(t *testing.T) {
|
||||
// Test plugin filtering by status
|
||||
func TestPluginWithStatusFilter(t *testing.T) {
|
||||
schema := rukiRuntime.NewSchema()
|
||||
pluginYAML := `
|
||||
name: In Progress Work
|
||||
key: W
|
||||
lanes:
|
||||
- name: Active
|
||||
filter: status IN ['ready', 'inProgress', 'inProgress']
|
||||
filter: select where status = "ready" or status = "inProgress"
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse plugin: %v", err)
|
||||
t.Fatalf("failed to parse plugin: %v", err)
|
||||
}
|
||||
|
||||
tp, ok := def.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected TikiPlugin, got %T", def)
|
||||
t.Fatalf("expected TikiPlugin, got %T", def)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
|
|
@ -153,23 +143,27 @@ lanes:
|
|||
status task.Status
|
||||
expect bool
|
||||
}{
|
||||
{"todo status", task.StatusReady, true},
|
||||
{"ready status", task.StatusReady, true},
|
||||
{"inProgress status", task.StatusInProgress, true},
|
||||
{"blocked status", task.StatusInProgress, true},
|
||||
{"done status", task.StatusDone, false},
|
||||
{"review status", task.StatusReview, false},
|
||||
}
|
||||
|
||||
executor := newTestExecutor()
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task := &task.Task{
|
||||
ID: "TIKI-1",
|
||||
Status: tc.status,
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Status: tc.status},
|
||||
}
|
||||
|
||||
result := tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser")
|
||||
if result != tc.expect {
|
||||
t.Errorf("Expected %v for status %s, got %v", tc.expect, tc.status, result)
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("executor error: %v", err)
|
||||
}
|
||||
|
||||
got := len(result.Select.Tasks) > 0
|
||||
if got != tc.expect {
|
||||
t.Errorf("expected match=%v for status %s, got %v", tc.expect, tc.status, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
585
plugin/legacy_convert.go
Normal file
585
plugin/legacy_convert.go
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// fieldNameMap maps lowercase field names to their canonical form.
|
||||
// Built once from workflow.Fields() + the "tag"→"tags" alias.
|
||||
var (
|
||||
fieldNameMap map[string]string
|
||||
fieldNameOnce sync.Once
|
||||
)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return canonical
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// isArrayField returns true if the normalized field name is a list type (tags, dependsOn).
|
||||
func isArrayField(name string) bool {
|
||||
canonical := normalizeFieldName(name)
|
||||
f, ok := workflow.Field(canonical)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return f.Type == workflow.TypeListString || f.Type == workflow.TypeListRef
|
||||
}
|
||||
|
||||
// LegacyConfigTransformer converts old-format workflow expressions to ruki.
|
||||
type LegacyConfigTransformer struct{}
|
||||
|
||||
// NewLegacyConfigTransformer creates a new transformer.
|
||||
func NewLegacyConfigTransformer() *LegacyConfigTransformer {
|
||||
return &LegacyConfigTransformer{}
|
||||
}
|
||||
|
||||
// ConvertPluginConfig converts all legacy expressions in a plugin config in-place.
|
||||
// Returns the number of fields converted.
|
||||
func (t *LegacyConfigTransformer) ConvertPluginConfig(cfg *pluginFileConfig) int {
|
||||
count := 0
|
||||
|
||||
// convert sort once (shared across lanes)
|
||||
var convertedSort string
|
||||
if cfg.Sort != "" {
|
||||
convertedSort = t.ConvertSort(cfg.Sort)
|
||||
slog.Warn("workflow.yaml uses deprecated 'sort' field — consider using 'order by' in lane filters",
|
||||
"plugin", cfg.Name)
|
||||
}
|
||||
|
||||
// convert lane filters and actions
|
||||
for i := range cfg.Lanes {
|
||||
lane := &cfg.Lanes[i]
|
||||
|
||||
if lane.Filter != "" && !isRukiFilter(lane.Filter) {
|
||||
newFilter := t.ConvertFilter(lane.Filter)
|
||||
slog.Debug("converted legacy filter", "old", lane.Filter, "new", newFilter, "lane", lane.Name)
|
||||
lane.Filter = newFilter
|
||||
count++
|
||||
}
|
||||
|
||||
// merge sort into lane filter
|
||||
if convertedSort != "" {
|
||||
lane.Filter = mergeSortIntoFilter(lane.Filter, convertedSort)
|
||||
}
|
||||
|
||||
if lane.Action != "" && !isRukiAction(lane.Action) {
|
||||
newAction, err := t.ConvertAction(lane.Action)
|
||||
if err != nil {
|
||||
slog.Warn("failed to convert legacy action, passing through",
|
||||
"error", err, "action", lane.Action, "lane", lane.Name)
|
||||
continue
|
||||
}
|
||||
slog.Debug("converted legacy action", "old", lane.Action, "new", newAction, "lane", lane.Name)
|
||||
lane.Action = newAction
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// convert plugin-level shortcut actions
|
||||
for i := range cfg.Actions {
|
||||
action := &cfg.Actions[i]
|
||||
if action.Action != "" && !isRukiAction(action.Action) {
|
||||
newAction, err := t.ConvertAction(action.Action)
|
||||
if err != nil {
|
||||
slog.Warn("failed to convert legacy plugin action, passing through",
|
||||
"error", err, "action", action.Action, "key", action.Key)
|
||||
continue
|
||||
}
|
||||
slog.Debug("converted legacy plugin action", "old", action.Action, "new", newAction, "key", action.Key)
|
||||
action.Action = newAction
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// clear sort after merging
|
||||
if cfg.Sort != "" {
|
||||
count++
|
||||
cfg.Sort = ""
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// isRukiFilter returns true if the expression is already in ruki format.
|
||||
func isRukiFilter(expr string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(expr)), "select")
|
||||
}
|
||||
|
||||
// isRukiAction returns true if the expression is already in ruki format.
|
||||
func isRukiAction(expr string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(expr))
|
||||
return strings.HasPrefix(lower, "update") ||
|
||||
strings.HasPrefix(lower, "create") ||
|
||||
strings.HasPrefix(lower, "delete")
|
||||
}
|
||||
|
||||
// mergeSortIntoFilter appends an order-by clause to a filter, respecting existing order-by.
|
||||
func mergeSortIntoFilter(filter, orderBy string) string {
|
||||
if strings.Contains(strings.ToLower(filter), "order by") {
|
||||
return filter
|
||||
}
|
||||
if filter == "" {
|
||||
return "select " + orderBy
|
||||
}
|
||||
return filter + " " + orderBy
|
||||
}
|
||||
|
||||
// --- Filter conversion ---
|
||||
//
|
||||
// Transformation order (documented for maintenance):
|
||||
// 1. Single quotes → double quotes
|
||||
// 2. == → = (before any =-based pattern matching)
|
||||
// 3. tag singular alias rewrites (before generic keyword lowering)
|
||||
// 4. tags/array field IN/NOT IN expansion (before generic IN lowering)
|
||||
// 5. NOT IN → not in (before standalone NOT)
|
||||
// 6. Remaining keyword lowering: AND, OR, NOT, IN
|
||||
// 7. NOW → now(), CURRENT_USER → user()
|
||||
// 8. Field name normalization
|
||||
// 9. Duration plural stripping
|
||||
|
||||
// pre-compiled regexes for filter conversion
|
||||
var (
|
||||
// step 1: single quotes → double quotes
|
||||
reSingleQuoted = regexp.MustCompile(`'([^']*)'`)
|
||||
|
||||
// step 2: == → =
|
||||
reDoubleEquals = regexp.MustCompile(`==`)
|
||||
|
||||
// step 3: tag singular alias — equality: tag = "value" → "value" in tags
|
||||
reTagEquality = regexp.MustCompile(`(?i)\btag\b\s*=\s*("(?:[^"]*)")`)
|
||||
|
||||
// step 3: tag singular alias — IN: tag IN [...] (captured together with tags IN below)
|
||||
// step 4: field IN [...] and field NOT IN [...]
|
||||
reFieldNotIn = regexp.MustCompile(`(?i)(\w+)\s+NOT\s+IN\s*\[([^\]]*)\]`)
|
||||
reFieldIn = regexp.MustCompile(`(?i)(\w+)\s+IN\s*\[([^\]]*)\]`)
|
||||
|
||||
// step 5: NOT IN → not in (word-bounded to avoid partial matches)
|
||||
reNotIn = regexp.MustCompile(`(?i)\bNOT\s+IN\b`)
|
||||
|
||||
// step 6: keyword lowering
|
||||
reAnd = regexp.MustCompile(`(?i)\bAND\b`)
|
||||
reOr = regexp.MustCompile(`(?i)\bOR\b`)
|
||||
reNot = regexp.MustCompile(`(?i)\bNOT\b`)
|
||||
reIn = regexp.MustCompile(`(?i)\bIN\b`)
|
||||
|
||||
// step 7: NOW → now(), CURRENT_USER → user()
|
||||
reNow = regexp.MustCompile(`(?i)\bNOW\b`)
|
||||
reCurrentUser = regexp.MustCompile(`(?i)\bCURRENT_USER\b`)
|
||||
|
||||
// step 8: field name normalization — matches identifiers before operators
|
||||
reFieldBeforeOp = regexp.MustCompile(`\b([a-zA-Z]\w*)\b`)
|
||||
|
||||
// step 9: duration plural stripping — e.g. 24hours → 24hour, 1weeks → 1week
|
||||
reDurationUnit = regexp.MustCompile(`(\d+)(hour|minute|day|week|month|year)s\b`)
|
||||
)
|
||||
|
||||
// ConvertFilter converts an old-format filter expression to a ruki select statement.
|
||||
func (t *LegacyConfigTransformer) ConvertFilter(filter string) string {
|
||||
if filter == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
s := strings.TrimSpace(filter)
|
||||
|
||||
// step 1: single quotes → double quotes
|
||||
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
|
||||
|
||||
// step 2: == → =
|
||||
s = reDoubleEquals.ReplaceAllString(s, "=")
|
||||
|
||||
// step 3: tag singular alias — equality
|
||||
// tag = "value" → "value" in tags
|
||||
s = reTagEquality.ReplaceAllStringFunc(s, func(match string) string {
|
||||
m := reTagEquality.FindStringSubmatch(match)
|
||||
return m[1] + " in tags"
|
||||
})
|
||||
|
||||
// step 3+4: field NOT IN [...] (must come before field IN)
|
||||
s = reFieldNotIn.ReplaceAllStringFunc(s, func(match string) string {
|
||||
m := reFieldNotIn.FindStringSubmatch(match)
|
||||
fieldName := m[1]
|
||||
values := m[2]
|
||||
return expandNotInClause(fieldName, values)
|
||||
})
|
||||
|
||||
// step 4: field IN [...]
|
||||
s = reFieldIn.ReplaceAllStringFunc(s, func(match string) string {
|
||||
m := reFieldIn.FindStringSubmatch(match)
|
||||
fieldName := m[1]
|
||||
values := m[2]
|
||||
return expandInClause(fieldName, values)
|
||||
})
|
||||
|
||||
// step 5: NOT IN → not in
|
||||
s = reNotIn.ReplaceAllString(s, "not in")
|
||||
|
||||
// step 6: keyword lowering
|
||||
s = reAnd.ReplaceAllString(s, "and")
|
||||
s = reOr.ReplaceAllString(s, "or")
|
||||
s = reNot.ReplaceAllString(s, "not")
|
||||
s = reIn.ReplaceAllString(s, "in")
|
||||
|
||||
// step 7: NOW → now(), CURRENT_USER → user()
|
||||
s = reNow.ReplaceAllString(s, "now()")
|
||||
s = reCurrentUser.ReplaceAllString(s, "user()")
|
||||
|
||||
// step 8: field name normalization
|
||||
s = normalizeFieldNames(s)
|
||||
|
||||
// step 9: duration plural stripping
|
||||
s = reDurationUnit.ReplaceAllString(s, "${1}${2}")
|
||||
|
||||
return "select where " + s
|
||||
}
|
||||
|
||||
// expandInClause handles field IN [...] expansion.
|
||||
// For array fields (tags, dependsOn): expands to ("v1" in field or "v2" in field).
|
||||
// For scalar fields: lowercases to field in [...].
|
||||
func expandInClause(fieldName, valuesStr string) string {
|
||||
if isArrayField(fieldName) {
|
||||
canonical := normalizeFieldName(fieldName)
|
||||
values := parseBracketValues(valuesStr)
|
||||
if len(values) == 1 {
|
||||
return values[0] + " in " + canonical
|
||||
}
|
||||
parts := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
parts[i] = v + " in " + canonical
|
||||
}
|
||||
return "(" + strings.Join(parts, " or ") + ")"
|
||||
}
|
||||
// scalar field: just lowercase the IN
|
||||
canonical := normalizeFieldName(fieldName)
|
||||
return canonical + " in [" + normalizeQuotedValues(valuesStr) + "]"
|
||||
}
|
||||
|
||||
// expandNotInClause handles field NOT IN [...] expansion.
|
||||
// For array fields: expands to ("v1" not in field and "v2" not in field).
|
||||
// For scalar fields: lowercases to field not in [...].
|
||||
func expandNotInClause(fieldName, valuesStr string) string {
|
||||
if isArrayField(fieldName) {
|
||||
canonical := normalizeFieldName(fieldName)
|
||||
values := parseBracketValues(valuesStr)
|
||||
if len(values) == 1 {
|
||||
return values[0] + " not in " + canonical
|
||||
}
|
||||
parts := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
parts[i] = v + " not in " + canonical
|
||||
}
|
||||
return "(" + strings.Join(parts, " and ") + ")"
|
||||
}
|
||||
canonical := normalizeFieldName(fieldName)
|
||||
return canonical + " not in [" + normalizeQuotedValues(valuesStr) + "]"
|
||||
}
|
||||
|
||||
// parseBracketValues parses a comma-separated list of values from inside [...].
|
||||
// Ensures all values are double-quoted.
|
||||
func parseBracketValues(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
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 {
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
p = strings.Trim(p, `"'`)
|
||||
result = append(result, `"`+p+`"`)
|
||||
}
|
||||
return strings.Join(result, ", ")
|
||||
}
|
||||
|
||||
// normalizeFieldNames replaces known field names with their canonical forms.
|
||||
// Only replaces identifiers outside of double-quoted strings.
|
||||
func normalizeFieldNames(s string) string {
|
||||
buildFieldNameMap()
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
// skip quoted strings verbatim
|
||||
if s[i] == '"' {
|
||||
j := i + 1
|
||||
for j < len(s) && s[j] != '"' {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++ // include closing quote
|
||||
}
|
||||
b.WriteString(s[i:j])
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// try to match an identifier at current position
|
||||
loc := reFieldBeforeOp.FindStringIndex(s[i:])
|
||||
if loc == nil {
|
||||
b.WriteString(s[i:])
|
||||
break
|
||||
}
|
||||
|
||||
// check if there's a quote in the gap before this match — if so,
|
||||
// only advance to that quote and let the next iteration skip it
|
||||
gap := s[i : i+loc[0]]
|
||||
if qIdx := strings.IndexByte(gap, '"'); qIdx >= 0 {
|
||||
b.WriteString(s[i : i+qIdx])
|
||||
i += qIdx
|
||||
continue
|
||||
}
|
||||
|
||||
// write non-identifier text before the match
|
||||
b.WriteString(gap)
|
||||
match := s[i+loc[0] : i+loc[1]]
|
||||
|
||||
lower := strings.ToLower(match)
|
||||
if canonical, ok := fieldNameMap[lower]; ok {
|
||||
b.WriteString(canonical)
|
||||
} else {
|
||||
b.WriteString(match)
|
||||
}
|
||||
i += loc[1]
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Sort conversion ---
|
||||
|
||||
// ConvertSort converts an old-format sort expression to a ruki order-by clause.
|
||||
func (t *LegacyConfigTransformer) ConvertSort(sort string) string {
|
||||
if sort == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(sort, ",")
|
||||
converted := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
tokens := strings.Fields(p)
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
fieldName := normalizeFieldName(tokens[0])
|
||||
if len(tokens) > 1 && strings.EqualFold(tokens[1], "DESC") {
|
||||
converted = append(converted, fieldName+" desc")
|
||||
} else {
|
||||
converted = append(converted, fieldName)
|
||||
}
|
||||
}
|
||||
return "order by " + strings.Join(converted, ", ")
|
||||
}
|
||||
|
||||
// --- Action conversion ---
|
||||
|
||||
// ConvertAction converts an old-format action expression to a ruki update statement.
|
||||
func (t *LegacyConfigTransformer) ConvertAction(action string) (string, error) {
|
||||
if action == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
segments, err := splitTopLevelCommas(strings.TrimSpace(action))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("splitting action segments: %w", err)
|
||||
}
|
||||
|
||||
setParts := make([]string, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
part, err := convertActionSegment(seg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("converting segment %q: %w", seg, err)
|
||||
}
|
||||
setParts = append(setParts, part)
|
||||
}
|
||||
|
||||
return "update where id = id() set " + strings.Join(setParts, " "), nil
|
||||
}
|
||||
|
||||
// convertActionSegment converts a single assignment like "status='ready'" or "tags+=[frontend]".
|
||||
func convertActionSegment(seg string) (string, error) {
|
||||
// detect += and -= operators first
|
||||
if idx := strings.Index(seg, "+="); idx > 0 {
|
||||
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
|
||||
value := strings.TrimSpace(seg[idx+2:])
|
||||
converted := convertBracketValues(value)
|
||||
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)
|
||||
return fieldName + "=" + fieldName + "-" + converted, nil
|
||||
}
|
||||
|
||||
// simple = assignment
|
||||
idx := strings.Index(seg, "=")
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("no assignment operator found in %q", seg)
|
||||
}
|
||||
// handle == (old format uses both = and ==)
|
||||
fieldName := normalizeFieldName(strings.TrimSpace(seg[:idx]))
|
||||
value := strings.TrimSpace(seg[idx+1:])
|
||||
if strings.HasPrefix(value, "=") {
|
||||
value = strings.TrimSpace(value[1:]) // skip second =
|
||||
}
|
||||
|
||||
// convert single quotes to double quotes
|
||||
value = reSingleQuoted.ReplaceAllString(value, `"$1"`)
|
||||
// convert CURRENT_USER
|
||||
value = reCurrentUser.ReplaceAllString(value, "user()")
|
||||
// quote bare identifiers
|
||||
value = quoteIfBareIdentifier(value)
|
||||
|
||||
return fieldName + "=" + value, nil
|
||||
}
|
||||
|
||||
// convertBracketValues converts a bracket-enclosed list, quoting bare identifiers.
|
||||
// e.g. [frontend, 'needs review'] → ["frontend", "needs review"]
|
||||
func convertBracketValues(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
|
||||
// not bracket-enclosed, just quote it
|
||||
s = reSingleQuoted.ReplaceAllString(s, `"$1"`)
|
||||
return quoteIfBareIdentifier(s)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// strip existing quotes
|
||||
p = strings.Trim(p, `"'`)
|
||||
// re-quote — all values in action brackets must be strings
|
||||
converted = append(converted, `"`+p+`"`)
|
||||
}
|
||||
return "[" + strings.Join(converted, ", ") + "]"
|
||||
}
|
||||
|
||||
var (
|
||||
// matches function calls like now(), user(), id()
|
||||
reFunctionCall = regexp.MustCompile(`^\w+\(\)$`)
|
||||
// matches numeric values (int or float)
|
||||
reNumeric = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// matches bare identifiers (not already quoted, not numeric, not a function)
|
||||
reBareIdentifier = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
||||
)
|
||||
|
||||
// quoteIfBareIdentifier wraps a value in double quotes if it's a bare identifier
|
||||
// (not numeric, not a function call, not already quoted).
|
||||
func quoteIfBareIdentifier(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
if strings.HasPrefix(value, `"`) {
|
||||
return value // already quoted
|
||||
}
|
||||
if reNumeric.MatchString(value) {
|
||||
return value // numeric
|
||||
}
|
||||
if reFunctionCall.MatchString(value) {
|
||||
return value // function call
|
||||
}
|
||||
if reBareIdentifier.MatchString(value) {
|
||||
return `"` + value + `"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// splitTopLevelCommas splits a string on commas, respecting [...] brackets and quotes.
|
||||
func splitTopLevelCommas(input string) ([]string, error) {
|
||||
var result []string
|
||||
var current strings.Builder
|
||||
depth := 0
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
|
||||
for i := 0; i < len(input); i++ {
|
||||
ch := input[i]
|
||||
|
||||
switch {
|
||||
case ch == '\'' && !inDouble:
|
||||
inSingle = !inSingle
|
||||
current.WriteByte(ch)
|
||||
case ch == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
current.WriteByte(ch)
|
||||
case ch == '[' && !inSingle && !inDouble:
|
||||
depth++
|
||||
current.WriteByte(ch)
|
||||
case ch == ']' && !inSingle && !inDouble:
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return nil, fmt.Errorf("unmatched ']' at position %d", i)
|
||||
}
|
||||
current.WriteByte(ch)
|
||||
case ch == ',' && depth == 0 && !inSingle && !inDouble:
|
||||
result = append(result, current.String())
|
||||
current.Reset()
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
|
||||
if depth != 0 {
|
||||
return nil, fmt.Errorf("unmatched '[' in expression")
|
||||
}
|
||||
if inSingle || inDouble {
|
||||
return nil, fmt.Errorf("unclosed quote in expression")
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
result = append(result, current.String())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
943
plugin/legacy_convert_test.go
Normal file
943
plugin/legacy_convert_test.go
Normal file
|
|
@ -0,0 +1,943 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestConvertLegacyFilter(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
old string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "simple comparison",
|
||||
old: "status = 'ready'",
|
||||
want: `select where status = "ready"`,
|
||||
},
|
||||
{
|
||||
name: "multi-condition and",
|
||||
old: "status = 'ready' AND type != 'epic'",
|
||||
want: `select where status = "ready" and type != "epic"`,
|
||||
},
|
||||
{
|
||||
name: "time expression with NOW",
|
||||
old: "NOW - UpdatedAt < 24hours",
|
||||
want: `select where now() - updatedAt < 24hour`,
|
||||
},
|
||||
{
|
||||
name: "duration plural weeks",
|
||||
old: "NOW - CreatedAt < 1weeks",
|
||||
want: `select where now() - createdAt < 1week`,
|
||||
},
|
||||
{
|
||||
name: "tags IN array expansion",
|
||||
old: "tags IN ['ui', 'charts']",
|
||||
want: `select where ("ui" in tags or "charts" in tags)`,
|
||||
},
|
||||
{
|
||||
name: "tags IN single element",
|
||||
old: "tags IN ['ui']",
|
||||
want: `select where "ui" in tags`,
|
||||
},
|
||||
{
|
||||
name: "tags NOT IN array expansion",
|
||||
old: "tags NOT IN ['ui', 'old']",
|
||||
want: `select where ("ui" not in tags and "old" not in tags)`,
|
||||
},
|
||||
{
|
||||
name: "status IN scalar",
|
||||
old: "status IN ['ready', 'inProgress']",
|
||||
want: `select where status in ["ready", "inProgress"]`,
|
||||
},
|
||||
{
|
||||
name: "status NOT IN scalar",
|
||||
old: "status NOT IN ['done']",
|
||||
want: `select where status not in ["done"]`,
|
||||
},
|
||||
{
|
||||
name: "NOT with parens",
|
||||
old: "NOT (status = 'done')",
|
||||
want: `select where not (status = "done")`,
|
||||
},
|
||||
{
|
||||
name: "tag singular alias equality",
|
||||
old: "tag = 'ui'",
|
||||
want: `select where "ui" in tags`,
|
||||
},
|
||||
{
|
||||
name: "tag singular alias IN",
|
||||
old: "tag IN ['ui', 'charts']",
|
||||
want: `select where ("ui" in tags or "charts" in tags)`,
|
||||
},
|
||||
{
|
||||
name: "tag singular alias NOT IN",
|
||||
old: "tag NOT IN ['ui', 'old']",
|
||||
want: `select where ("ui" not in tags and "old" not in tags)`,
|
||||
},
|
||||
{
|
||||
name: "CURRENT_USER",
|
||||
old: "assignee = CURRENT_USER",
|
||||
want: `select where assignee = user()`,
|
||||
},
|
||||
{
|
||||
name: "double equals",
|
||||
old: "priority == 3",
|
||||
want: `select where priority = 3`,
|
||||
},
|
||||
{
|
||||
name: "mixed case keywords",
|
||||
old: "type = 'epic' And status = 'ready'",
|
||||
want: `select where type = "epic" and status = "ready"`,
|
||||
},
|
||||
{
|
||||
name: "numeric comparison",
|
||||
old: "priority > 2",
|
||||
want: `select where priority > 2`,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
old: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "passthrough already ruki",
|
||||
old: "select where status = \"ready\"",
|
||||
want: "select where status = \"ready\"",
|
||||
},
|
||||
{
|
||||
name: "whitespace variations",
|
||||
old: " status = 'ready' ",
|
||||
want: `select where status = "ready"`,
|
||||
},
|
||||
{
|
||||
name: "field name normalization CreatedAt",
|
||||
old: "CreatedAt > 0",
|
||||
want: `select where createdAt > 0`,
|
||||
},
|
||||
{
|
||||
name: "field name normalization Priority",
|
||||
old: "Priority > 2",
|
||||
want: `select where priority > 2`,
|
||||
},
|
||||
{
|
||||
name: "dependsOn field normalization",
|
||||
old: "DependsOn IN ['TIKI-ABC123']",
|
||||
want: `select where "TIKI-ABC123" in dependsOn`,
|
||||
},
|
||||
{
|
||||
name: "case variations Or",
|
||||
old: "status = 'ready' Or status = 'done'",
|
||||
want: `select where status = "ready" or status = "done"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// passthrough check
|
||||
if tt.old != "" && isRukiFilter(tt.old) {
|
||||
if tt.old != tt.want {
|
||||
t.Errorf("passthrough mismatch: got %q, want %q", tt.old, tt.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := tr.ConvertFilter(tt.old)
|
||||
if got != tt.want {
|
||||
t.Errorf("ConvertFilter(%q)\n got: %q\n want: %q", tt.old, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacySort(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
old string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single field",
|
||||
old: "Priority",
|
||||
want: "order by priority",
|
||||
},
|
||||
{
|
||||
name: "multi field",
|
||||
old: "Priority, CreatedAt",
|
||||
want: "order by priority, createdAt",
|
||||
},
|
||||
{
|
||||
name: "DESC",
|
||||
old: "UpdatedAt DESC",
|
||||
want: "order by updatedAt desc",
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
old: "Priority, Points DESC",
|
||||
want: "order by priority, points desc",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
old: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tr.ConvertSort(tt.old)
|
||||
if got != tt.want {
|
||||
t.Errorf("ConvertSort(%q)\n got: %q\n want: %q", tt.old, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacySortMerging(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sort string
|
||||
lanes []PluginLaneConfig
|
||||
wantFilter []string // expected filter for each lane after merge
|
||||
}{
|
||||
{
|
||||
name: "sort + non-empty lane filter",
|
||||
sort: "Priority",
|
||||
lanes: []PluginLaneConfig{
|
||||
{Name: "ready", Filter: "status = 'ready'"},
|
||||
},
|
||||
wantFilter: []string{`select where status = "ready" order by priority`},
|
||||
},
|
||||
{
|
||||
name: "sort + empty lane filter",
|
||||
sort: "Priority",
|
||||
lanes: []PluginLaneConfig{
|
||||
{Name: "all", Filter: ""},
|
||||
},
|
||||
wantFilter: []string{"select order by priority"},
|
||||
},
|
||||
{
|
||||
name: "sort + lane already has order by",
|
||||
sort: "Priority",
|
||||
lanes: []PluginLaneConfig{
|
||||
{Name: "custom", Filter: "select where status = \"ready\" order by updatedAt desc"},
|
||||
},
|
||||
wantFilter: []string{`select where status = "ready" order by updatedAt desc`},
|
||||
},
|
||||
{
|
||||
name: "no sort field",
|
||||
sort: "",
|
||||
lanes: []PluginLaneConfig{
|
||||
{Name: "ready", Filter: "status = 'ready'"},
|
||||
},
|
||||
wantFilter: []string{`select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "test",
|
||||
Sort: tt.sort,
|
||||
Lanes: tt.lanes,
|
||||
}
|
||||
tr.ConvertPluginConfig(cfg)
|
||||
|
||||
if len(cfg.Lanes) != len(tt.wantFilter) {
|
||||
t.Fatalf("lane count mismatch: got %d, want %d", len(cfg.Lanes), len(tt.wantFilter))
|
||||
}
|
||||
for i, want := range tt.wantFilter {
|
||||
if cfg.Lanes[i].Filter != want {
|
||||
t.Errorf("lane %d filter:\n got: %q\n want: %q", i, cfg.Lanes[i].Filter, want)
|
||||
}
|
||||
}
|
||||
if tt.sort != "" && cfg.Sort != "" {
|
||||
t.Error("Sort field was not cleared after merging")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacyAction(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
old string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple status",
|
||||
old: "status = 'ready'",
|
||||
want: `update where id = id() set status="ready"`,
|
||||
},
|
||||
{
|
||||
name: "multiple assignments",
|
||||
old: "status = 'backlog', priority = 1",
|
||||
want: `update where id = id() set status="backlog" priority=1`,
|
||||
},
|
||||
{
|
||||
name: "tags add",
|
||||
old: "tags+=[frontend, 'needs review']",
|
||||
want: `update where id = id() set tags=tags+["frontend", "needs review"]`,
|
||||
},
|
||||
{
|
||||
name: "tags remove",
|
||||
old: "tags-=[old]",
|
||||
want: `update where id = id() set tags=tags-["old"]`,
|
||||
},
|
||||
{
|
||||
name: "dependsOn add",
|
||||
old: "dependsOn+=[TIKI-ABC123]",
|
||||
want: `update where id = id() set dependsOn=dependsOn+["TIKI-ABC123"]`,
|
||||
},
|
||||
{
|
||||
name: "CURRENT_USER",
|
||||
old: "assignee=CURRENT_USER",
|
||||
want: `update where id = id() set assignee=user()`,
|
||||
},
|
||||
{
|
||||
name: "unquoted string value",
|
||||
old: "status=done",
|
||||
want: `update where id = id() set status="done"`,
|
||||
},
|
||||
{
|
||||
name: "bare identifiers in brackets",
|
||||
old: "tags+=[frontend, backend]",
|
||||
want: `update where id = id() set tags=tags+["frontend", "backend"]`,
|
||||
},
|
||||
{
|
||||
name: "integer value",
|
||||
old: "priority=2",
|
||||
want: `update where id = id() set priority=2`,
|
||||
},
|
||||
{
|
||||
name: "multiple mixed",
|
||||
old: "status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend], dependsOn+=[TIKI-ABC123]",
|
||||
want: `update where id = id() set status="done" type="bug" priority=2 points=3 assignee="Alice" tags=tags+["frontend"] dependsOn=dependsOn+["TIKI-ABC123"]`,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
old: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "passthrough already ruki",
|
||||
old: `update where id = id() set status="ready"`,
|
||||
want: `update where id = id() set status="ready"`,
|
||||
},
|
||||
{
|
||||
name: "malformed brackets",
|
||||
old: "tags+=[frontend",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.old != "" && isRukiAction(tt.old) {
|
||||
// passthrough — already ruki
|
||||
if tt.old != tt.want {
|
||||
t.Errorf("passthrough mismatch: got %q, want %q", tt.old, tt.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
got, err := tr.ConvertAction(tt.old)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ConvertAction(%q) expected error, got %q", tt.old, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertAction(%q) unexpected error: %v", tt.old, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ConvertAction(%q)\n got: %q\n want: %q", tt.old, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacyPluginActions(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "test",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "all", Filter: ""},
|
||||
},
|
||||
Actions: []PluginActionConfig{
|
||||
{Key: "b", Label: "Ready", Action: "status = 'ready'"},
|
||||
{Key: "c", Label: "Done", Action: `update where id = id() set status="done"`},
|
||||
},
|
||||
}
|
||||
|
||||
tr.ConvertPluginConfig(cfg)
|
||||
|
||||
if cfg.Actions[0].Action != `update where id = id() set status="ready"` {
|
||||
t.Errorf("action 0 not converted: %q", cfg.Actions[0].Action)
|
||||
}
|
||||
if cfg.Actions[1].Action != `update where id = id() set status="done"` {
|
||||
t.Errorf("action 1 was modified: %q", cfg.Actions[1].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacyMixedFormats(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "test",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "old", Filter: "status = 'ready'", Action: "status = 'inProgress'"},
|
||||
{Name: "new", Filter: `select where status = "done"`, Action: `update where id = id() set status="done"`},
|
||||
},
|
||||
}
|
||||
|
||||
tr.ConvertPluginConfig(cfg)
|
||||
|
||||
// old lane should be converted
|
||||
if cfg.Lanes[0].Filter != `select where status = "ready"` {
|
||||
t.Errorf("old lane filter not converted: %q", cfg.Lanes[0].Filter)
|
||||
}
|
||||
if cfg.Lanes[0].Action != `update where id = id() set status="inProgress"` {
|
||||
t.Errorf("old lane action not converted: %q", cfg.Lanes[0].Action)
|
||||
}
|
||||
|
||||
// new lane should be unchanged
|
||||
if cfg.Lanes[1].Filter != `select where status = "done"` {
|
||||
t.Errorf("new lane filter was modified: %q", cfg.Lanes[1].Filter)
|
||||
}
|
||||
if cfg.Lanes[1].Action != `update where id = id() set status="done"` {
|
||||
t.Errorf("new lane action was modified: %q", cfg.Lanes[1].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacyActionPassthroughPrefixes(t *testing.T) {
|
||||
// create and delete prefixes must not be re-converted
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
}{
|
||||
{name: "create prefix", action: `create where type = "bug" set status="ready"`},
|
||||
{name: "delete prefix", action: `delete where id = id()`},
|
||||
{name: "update prefix", action: `update where id = id() set status="done"`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !isRukiAction(tt.action) {
|
||||
t.Errorf("isRukiAction(%q) returned false, expected true", tt.action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLegacyEdgeCases(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
t.Run("tag vs tags word boundary", func(t *testing.T) {
|
||||
// "tags IN" should not trigger the singular "tag" alias path
|
||||
got := tr.ConvertFilter("tags IN ['ui', 'charts']")
|
||||
want := `select where ("ui" in tags or "charts" in tags)`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tag NOT IN treated as tags NOT IN", func(t *testing.T) {
|
||||
got := tr.ConvertFilter("tag NOT IN ['ui', 'old']")
|
||||
want := `select where ("ui" not in tags and "old" not in tags)`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array NOT IN single element no parens", func(t *testing.T) {
|
||||
got := tr.ConvertFilter("tags NOT IN ['old']")
|
||||
want := `select where "old" not in tags`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array IN single element no parens", func(t *testing.T) {
|
||||
got := tr.ConvertFilter("tags IN ['ui']")
|
||||
want := `select where "ui" in tags`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("values with spaces in quotes", func(t *testing.T) {
|
||||
got := tr.ConvertFilter("status = 'in progress'")
|
||||
want := `select where status = "in progress"`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already double-quoted values", func(t *testing.T) {
|
||||
got := tr.ConvertFilter(`status = "ready"`)
|
||||
want := `select where status = "ready"`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dependsOn single element", func(t *testing.T) {
|
||||
got := tr.ConvertFilter("DependsOn IN ['TIKI-ABC123']")
|
||||
want := `select where "TIKI-ABC123" in dependsOn`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field name inside quotes not normalized", func(t *testing.T) {
|
||||
// "Type" as a string value must not be lowercased to "type"
|
||||
got := tr.ConvertFilter("title = 'Type'")
|
||||
want := `select where title = "Type"`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field name inside double quotes not normalized", func(t *testing.T) {
|
||||
got := tr.ConvertFilter(`assignee = "Status"`)
|
||||
want := `select where assignee = "Status"`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field name in bracket values not normalized", func(t *testing.T) {
|
||||
got := tr.ConvertFilter("status IN ['Priority', 'Type']")
|
||||
want := `select where status in ["Priority", "Type"]`
|
||||
if got != want {
|
||||
t.Errorf("got: %q\nwant: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertLegacyFullConfig(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "board",
|
||||
Sort: "Priority, CreatedAt",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Backlog", Filter: "status = 'backlog'", Action: "status = 'backlog'"},
|
||||
{Name: "Ready", Filter: "status = 'ready'", Action: "status = 'ready'"},
|
||||
{Name: "In Progress", Filter: "status = 'inProgress'", Action: "status = 'inProgress'"},
|
||||
{Name: "Done", Filter: "status = 'done'", Action: "status = 'done'"},
|
||||
},
|
||||
Actions: []PluginActionConfig{
|
||||
{Key: "b", Label: "Backlog", Action: "status = 'backlog'"},
|
||||
},
|
||||
}
|
||||
|
||||
count := tr.ConvertPluginConfig(cfg)
|
||||
|
||||
// 4 filters + 4 actions + 1 plugin action + 1 sort = 10
|
||||
if count != 10 {
|
||||
t.Errorf("expected 10 conversions, got %d", count)
|
||||
}
|
||||
|
||||
// verify sort was cleared
|
||||
if cfg.Sort != "" {
|
||||
t.Error("Sort field was not cleared")
|
||||
}
|
||||
|
||||
// verify all converted expressions parse through ruki
|
||||
schema := testSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
|
||||
for _, lane := range cfg.Lanes {
|
||||
if lane.Filter != "" {
|
||||
_, err := parser.ParseAndValidateStatement(lane.Filter, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Errorf("lane %q filter failed ruki parse: %v\n filter: %s", lane.Name, err, lane.Filter)
|
||||
}
|
||||
}
|
||||
if lane.Action != "" {
|
||||
_, err := parser.ParseAndValidateStatement(lane.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Errorf("lane %q action failed ruki parse: %v\n action: %s", lane.Name, err, lane.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, action := range cfg.Actions {
|
||||
if action.Action != "" {
|
||||
_, err := parser.ParseAndValidateStatement(action.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Errorf("plugin action %q failed ruki parse: %v\n action: %s", action.Key, err, action.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyWorkflowEndToEnd tests the full pipeline from legacy YAML string
|
||||
// through conversion and parsing to plugin creation and execution.
|
||||
func TestLegacyWorkflowEndToEnd(t *testing.T) {
|
||||
legacyYAML := `views:
|
||||
- name: Board
|
||||
default: true
|
||||
key: "F1"
|
||||
sort: Priority, CreatedAt
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: status = 'backlog' AND tags NOT IN ['blocked']
|
||||
action: status = 'backlog'
|
||||
- name: Ready
|
||||
filter: status = 'ready' AND assignee = CURRENT_USER
|
||||
action: status = 'ready', tags+=[reviewed]
|
||||
- name: Done
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
actions:
|
||||
- key: b
|
||||
label: Bug
|
||||
action: type = 'bug', priority = 1
|
||||
`
|
||||
|
||||
var wf WorkflowFile
|
||||
if err := yaml.Unmarshal([]byte(legacyYAML), &wf); err != nil {
|
||||
t.Fatalf("failed to unmarshal legacy YAML: %v", err)
|
||||
}
|
||||
|
||||
// convert legacy expressions
|
||||
transformer := NewLegacyConfigTransformer()
|
||||
for i := range wf.Plugins {
|
||||
transformer.ConvertPluginConfig(&wf.Plugins[i])
|
||||
}
|
||||
|
||||
// parse into plugin — this validates ruki parsing succeeds
|
||||
schema := testSchema()
|
||||
p, err := parsePluginConfig(wf.Plugins[0], "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePluginConfig failed: %v", err)
|
||||
}
|
||||
|
||||
tp, ok := p.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected TikiPlugin, got %T", p)
|
||||
}
|
||||
|
||||
if tp.Name != "Board" {
|
||||
t.Errorf("expected name Board, got %s", tp.Name)
|
||||
}
|
||||
if !tp.Default {
|
||||
t.Error("expected default=true")
|
||||
}
|
||||
if len(tp.Lanes) != 3 {
|
||||
t.Fatalf("expected 3 lanes, got %d", len(tp.Lanes))
|
||||
}
|
||||
if len(tp.Actions) != 1 {
|
||||
t.Fatalf("expected 1 action, got %d", len(tp.Actions))
|
||||
}
|
||||
|
||||
// verify all lanes have parsed filter and action statements
|
||||
for i, lane := range tp.Lanes {
|
||||
if lane.Filter == nil {
|
||||
t.Errorf("lane %d (%s): expected non-nil filter", i, lane.Name)
|
||||
}
|
||||
if !lane.Filter.IsSelect() {
|
||||
t.Errorf("lane %d (%s): expected select statement", i, lane.Name)
|
||||
}
|
||||
if lane.Action == nil {
|
||||
t.Errorf("lane %d (%s): expected non-nil action", i, lane.Name)
|
||||
}
|
||||
if !lane.Action.IsUpdate() {
|
||||
t.Errorf("lane %d (%s): expected update statement", i, lane.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// execute filters against test tasks to verify they actually work
|
||||
executor := newTestExecutor()
|
||||
|
||||
allTasks := []*task.Task{
|
||||
{ID: "TIKI-000001", Status: task.StatusBacklog, Priority: 3, Tags: []string{}, Assignee: "testuser"},
|
||||
{ID: "TIKI-000002", Status: task.StatusBacklog, Priority: 1, Tags: []string{"blocked"}, Assignee: "testuser"},
|
||||
{ID: "TIKI-000003", Status: task.StatusReady, Priority: 2, Assignee: "testuser"},
|
||||
{ID: "TIKI-000004", Status: task.StatusReady, Priority: 1, Assignee: "other"},
|
||||
{ID: "TIKI-000005", Status: task.StatusDone, Priority: 5, Assignee: "testuser"},
|
||||
}
|
||||
|
||||
// backlog lane: status='backlog' AND tags NOT IN ['blocked']
|
||||
result, err := executor.Execute(tp.Lanes[0].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("backlog filter execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000001" {
|
||||
t.Errorf("backlog lane: expected [TIKI-000001], got %v", taskIDs(result.Select.Tasks))
|
||||
}
|
||||
|
||||
// ready lane: status='ready' AND assignee = CURRENT_USER
|
||||
result, err = executor.Execute(tp.Lanes[1].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("ready filter execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000003" {
|
||||
t.Errorf("ready lane: expected [TIKI-000003], got %v", taskIDs(result.Select.Tasks))
|
||||
}
|
||||
|
||||
// done lane: status='done'
|
||||
result, err = executor.Execute(tp.Lanes[2].Filter, allTasks)
|
||||
if err != nil {
|
||||
t.Fatalf("done filter execute: %v", err)
|
||||
}
|
||||
if len(result.Select.Tasks) != 1 || result.Select.Tasks[0].ID != "TIKI-000005" {
|
||||
t.Errorf("done lane: expected [TIKI-000005], got %v", taskIDs(result.Select.Tasks))
|
||||
}
|
||||
|
||||
// verify sort was merged: backlog filter should have order by
|
||||
backlogFilter := wf.Plugins[0].Lanes[0].Filter
|
||||
if !strings.Contains(backlogFilter, "order by") {
|
||||
t.Errorf("expected sort merged into backlog filter, got: %s", backlogFilter)
|
||||
}
|
||||
|
||||
// execute ready lane action to verify tag append works
|
||||
actionResult, err := executor.Execute(tp.Lanes[1].Action, []*task.Task{
|
||||
{ID: "TIKI-000003", Status: task.StatusReady, Tags: []string{}},
|
||||
}, ruki.ExecutionInput{SelectedTaskID: "TIKI-000003"})
|
||||
if err != nil {
|
||||
t.Fatalf("ready action execute: %v", err)
|
||||
}
|
||||
updated := actionResult.Update.Updated
|
||||
if len(updated) != 1 {
|
||||
t.Fatalf("expected 1 updated task, got %d", len(updated))
|
||||
}
|
||||
if updated[0].Status != task.StatusReady {
|
||||
t.Errorf("expected status ready, got %v", updated[0].Status)
|
||||
}
|
||||
if !containsString(updated[0].Tags, "reviewed") {
|
||||
t.Errorf("expected 'reviewed' tag after action, got %v", updated[0].Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func taskIDs(tasks []*task.Task) []string {
|
||||
ids := make([]string, len(tasks))
|
||||
for i, t := range tasks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func containsString(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestConvertLegacyConversionCount(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
|
||||
// config with no legacy expressions
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "test",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "all", Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
count := tr.ConvertPluginConfig(cfg)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 conversions for already-ruki config, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTopLevelCommas(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple",
|
||||
input: "a, b, c",
|
||||
want: []string{"a", " b", " c"},
|
||||
},
|
||||
{
|
||||
name: "with brackets",
|
||||
input: "tags+=[a, b], status=done",
|
||||
want: []string{"tags+=[a, b]", " status=done"},
|
||||
},
|
||||
{
|
||||
name: "with quotes",
|
||||
input: "status='a, b', type=bug",
|
||||
want: []string{"status='a, b'", " type=bug"},
|
||||
},
|
||||
{
|
||||
name: "unmatched bracket",
|
||||
input: "tags+=[a, b",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "extra close bracket",
|
||||
input: "a]",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unclosed quote",
|
||||
input: "status='open",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := splitTopLevelCommas(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("length mismatch: got %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("segment %d: got %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteIfBareIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"done", `"done"`},
|
||||
{"ready", `"ready"`},
|
||||
{"42", "42"},
|
||||
{"3.14", "3.14"},
|
||||
{"-7", "-7"},
|
||||
{"-3.5", "-3.5"},
|
||||
{"now()", "now()"},
|
||||
{"user()", "user()"},
|
||||
{`"already"`, `"already"`},
|
||||
{"", ""},
|
||||
{"TIKI-ABC123", `"TIKI-ABC123"`},
|
||||
// not a bare identifier (contains space) — returned unchanged
|
||||
{"hello world", "hello world"},
|
||||
// starts with digit — not bare identifier, not numeric
|
||||
{"0xDEAD", "0xDEAD"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := quoteIfBareIdentifier(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("quoteIfBareIdentifier(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTopLevelCommas_UnclosedDoubleQuote(t *testing.T) {
|
||||
_, err := splitTopLevelCommas(`status="open, tags+=[a]`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unclosed double quote")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unclosed quote") {
|
||||
t.Errorf("expected 'unclosed quote' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertBracketValues_NonBracketEnclosed(t *testing.T) {
|
||||
// bare identifier without brackets should be quoted
|
||||
got := convertBracketValues("frontend")
|
||||
if got != `"frontend"` {
|
||||
t.Errorf("expected bare identifier to be quoted, got %q", got)
|
||||
}
|
||||
|
||||
// single-quoted value without brackets
|
||||
got = convertBracketValues("'needs review'")
|
||||
if got != `"needs review"` {
|
||||
t.Errorf("expected single-quoted value to be converted, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertActionSegment_DoubleEquals(t *testing.T) {
|
||||
got, err := convertActionSegment("status=='done'")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// == should be handled: first = is found, then value starts with = and is stripped
|
||||
if got != `status="done"` {
|
||||
t.Errorf("got %q, want %q", got, `status="done"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAction_NoAssignmentOperator(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
_, err := tr.ConvertAction("garbage_without_equals")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no assignment operator")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no assignment operator") {
|
||||
t.Errorf("expected 'no assignment operator' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPluginConfig_ActionConvertError(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "test",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "all", Filter: ""},
|
||||
// lane action with malformed brackets should be skipped with a warning
|
||||
{Name: "bad", Filter: "", Action: "tags+=[unclosed"},
|
||||
},
|
||||
}
|
||||
count := tr.ConvertPluginConfig(cfg)
|
||||
// the malformed action should be skipped (not counted), but lane filter is empty (not counted)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 conversions for malformed action, got %d", count)
|
||||
}
|
||||
// the action should remain unchanged due to conversion failure
|
||||
if cfg.Lanes[1].Action != "tags+=[unclosed" {
|
||||
t.Errorf("malformed action should be passed through unchanged, got %q", cfg.Lanes[1].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPluginConfig_PluginActionConvertError(t *testing.T) {
|
||||
tr := NewLegacyConfigTransformer()
|
||||
cfg := &pluginFileConfig{
|
||||
Name: "test",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "all", Filter: ""},
|
||||
},
|
||||
Actions: []PluginActionConfig{
|
||||
{Key: "b", Label: "Bad", Action: "tags+=[unclosed"},
|
||||
},
|
||||
}
|
||||
count := tr.ConvertPluginConfig(cfg)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 conversions for malformed plugin action, got %d", count)
|
||||
}
|
||||
if cfg.Actions[0].Action != "tags+=[unclosed" {
|
||||
t.Errorf("malformed plugin action should be passed through unchanged, got %q", cfg.Actions[0].Action)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ type WorkflowFile struct {
|
|||
|
||||
// loadPluginsFromFile loads plugins from a single workflow.yaml file.
|
||||
// Returns the successfully loaded plugins and any validation errors encountered.
|
||||
func loadPluginsFromFile(path string) ([]Plugin, []string) {
|
||||
func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
slog.Warn("failed to read workflow.yaml", "path", path, "error", err)
|
||||
|
|
@ -34,6 +35,16 @@ func loadPluginsFromFile(path string) ([]Plugin, []string) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// convert legacy expressions to ruki before parsing
|
||||
transformer := NewLegacyConfigTransformer()
|
||||
totalConverted := 0
|
||||
for i := range wf.Plugins {
|
||||
totalConverted += transformer.ConvertPluginConfig(&wf.Plugins[i])
|
||||
}
|
||||
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 {
|
||||
|
|
@ -45,7 +56,7 @@ func loadPluginsFromFile(path string) ([]Plugin, []string) {
|
|||
}
|
||||
|
||||
source := fmt.Sprintf("%s:%s", path, cfg.Name)
|
||||
p, err := parsePluginConfig(cfg, source)
|
||||
p, err := parsePluginConfig(cfg, source, schema)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("%s: view %q: %v", path, cfg.Name, err)
|
||||
slog.Warn("failed to load plugin from workflow.yaml", "name", cfg.Name, "error", err)
|
||||
|
|
@ -107,7 +118,7 @@ func mergePluginLists(base, overrides []Plugin) []Plugin {
|
|||
// Files are discovered via config.FindWorkflowFiles() which returns user config first, then project config.
|
||||
// Plugins from later files override same-named plugins from earlier files via field merging.
|
||||
// Returns an error when workflow files were found but no valid plugins could be loaded.
|
||||
func LoadPlugins() ([]Plugin, error) {
|
||||
func LoadPlugins(schema ruki.Schema) ([]Plugin, error) {
|
||||
files := config.FindWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
slog.Debug("no workflow.yaml files found")
|
||||
|
|
@ -117,12 +128,12 @@ func LoadPlugins() ([]Plugin, error) {
|
|||
var allErrors []string
|
||||
|
||||
// First file is the base (typically user config)
|
||||
base, errs := loadPluginsFromFile(files[0])
|
||||
base, errs := loadPluginsFromFile(files[0], schema)
|
||||
allErrors = append(allErrors, errs...)
|
||||
|
||||
// Remaining files are overrides, merged in order
|
||||
for _, path := range files[1:] {
|
||||
overrides, errs := loadPluginsFromFile(path)
|
||||
overrides, errs := loadPluginsFromFile(path, schema)
|
||||
allErrors = append(allErrors, errs...)
|
||||
if len(overrides) > 0 {
|
||||
base = mergePluginLists(base, overrides)
|
||||
|
|
|
|||
|
|
@ -4,26 +4,24 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestParsePluginConfig_FullyInline(t *testing.T) {
|
||||
schema := testSchema()
|
||||
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Inline Test",
|
||||
Foreground: "#ffffff",
|
||||
Background: "#000000",
|
||||
Key: "I",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: "status = 'ready'"},
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
Sort: "Priority DESC",
|
||||
View: "expanded",
|
||||
Default: true,
|
||||
}
|
||||
|
||||
def, err := parsePluginConfig(cfg, "test")
|
||||
def, err := parsePluginConfig(cfg, "test", schema)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -49,34 +47,24 @@ func TestParsePluginConfig_FullyInline(t *testing.T) {
|
|||
t.Fatal("Expected lane filter to be parsed")
|
||||
}
|
||||
|
||||
if len(tp.Sort) != 1 || tp.Sort[0].Field != "priority" || !tp.Sort[0].Descending {
|
||||
t.Errorf("Expected sort 'Priority DESC', got %+v", tp.Sort)
|
||||
if !tp.Lanes[0].Filter.IsSelect() {
|
||||
t.Error("Expected lane filter to be a SELECT statement")
|
||||
}
|
||||
|
||||
if !tp.IsDefault() {
|
||||
t.Error("Expected IsDefault() to return true")
|
||||
}
|
||||
|
||||
// test filter evaluation
|
||||
task := &taskpkg.Task{
|
||||
ID: "TIKI-1",
|
||||
Status: taskpkg.StatusReady,
|
||||
}
|
||||
|
||||
if !tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser") {
|
||||
t.Error("Expected filter to match todo task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_Minimal(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Minimal",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bugs", Filter: "type = 'bug'"},
|
||||
{Name: "Bugs", Filter: `select where type = "bug"`},
|
||||
},
|
||||
}
|
||||
|
||||
def, err := parsePluginConfig(cfg, "test")
|
||||
def, err := parsePluginConfig(cfg, "test", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -98,11 +86,11 @@ func TestParsePluginConfig_Minimal(t *testing.T) {
|
|||
func TestParsePluginConfig_NoName(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: "status = 'ready'"},
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test")
|
||||
_, err := parsePluginConfig(cfg, "test", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for plugin without name")
|
||||
}
|
||||
|
|
@ -117,7 +105,7 @@ func TestPluginTypeExplicit(t *testing.T) {
|
|||
Text: "some text",
|
||||
}
|
||||
|
||||
def, err := parsePluginConfig(cfg, "test")
|
||||
def, err := parsePluginConfig(cfg, "test", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -140,8 +128,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
|
|||
key: "F5"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready'
|
||||
sort: Priority
|
||||
filter: select where status = "ready"
|
||||
- name: TestDocs
|
||||
type: doki
|
||||
fetcher: internal
|
||||
|
|
@ -153,7 +140,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
|
|||
t.Fatalf("Failed to write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
plugins, errs := loadPluginsFromFile(workflowPath)
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("Expected no errors, got: %v", errs)
|
||||
}
|
||||
|
|
@ -187,7 +174,7 @@ func TestLoadPluginsFromFile_WorkflowFile(t *testing.T) {
|
|||
|
||||
func TestLoadPluginsFromFile_NoFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"))
|
||||
plugins, errs := loadPluginsFromFile(filepath.Join(tmpDir, "workflow.yaml"), testSchema())
|
||||
if plugins != nil {
|
||||
t.Errorf("Expected nil plugins when no workflow.yaml, got %d", len(plugins))
|
||||
}
|
||||
|
|
@ -203,7 +190,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
|
|||
key: "V"
|
||||
lanes:
|
||||
- name: Todo
|
||||
filter: status = 'ready'
|
||||
filter: select where status = "ready"
|
||||
- name: Invalid
|
||||
type: unknown
|
||||
`
|
||||
|
|
@ -213,7 +200,7 @@ func TestLoadPluginsFromFile_InvalidPlugin(t *testing.T) {
|
|||
}
|
||||
|
||||
// should load valid plugin and skip invalid one
|
||||
plugins, errs := loadPluginsFromFile(workflowPath)
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("Expected 1 valid plugin (invalid skipped), got %d", len(plugins))
|
||||
}
|
||||
|
|
@ -249,6 +236,186 @@ func TestDefaultPlugin_NoDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsFromFile_LegacyConversion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// workflow with legacy filter expressions that need conversion
|
||||
workflowContent := `views:
|
||||
- name: Board
|
||||
key: "F5"
|
||||
sort: Priority
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready'
|
||||
action: status = 'inProgress'
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatalf("write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
plugins, 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))
|
||||
}
|
||||
|
||||
tp, ok := plugins[0].(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected TikiPlugin, got %T", plugins[0])
|
||||
}
|
||||
|
||||
// filter should have been converted and parsed (with order by from sort)
|
||||
if tp.Lanes[0].Filter == nil {
|
||||
t.Fatal("expected filter to be parsed after legacy conversion")
|
||||
}
|
||||
if !tp.Lanes[0].Filter.IsSelect() {
|
||||
t.Error("expected SELECT filter after conversion")
|
||||
}
|
||||
if tp.Lanes[0].Action == nil {
|
||||
t.Fatal("expected action to be parsed after legacy conversion")
|
||||
}
|
||||
if !tp.Lanes[0].Action.IsUpdate() {
|
||||
t.Error("expected UPDATE action after conversion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsFromFile_UnnamedPlugin(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workflowContent := `views:
|
||||
- name: Valid
|
||||
key: "V"
|
||||
lanes:
|
||||
- name: Todo
|
||||
filter: select where status = "ready"
|
||||
- lanes:
|
||||
- name: Bad
|
||||
filter: select where status = "done"
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatalf("write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected 1 error for unnamed plugin, got %d: %v", len(errs), errs)
|
||||
}
|
||||
if plugins[0].GetName() != "Valid" {
|
||||
t.Errorf("expected plugin 'Valid', got %q", plugins[0].GetName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsFromFile_InvalidYAML(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte("invalid: yaml: content: ["), 0644); err != nil {
|
||||
t.Fatalf("write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if plugins != nil {
|
||||
t.Error("expected nil plugins for invalid YAML")
|
||||
}
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("expected 1 error for invalid YAML, got %d: %v", len(errs), errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsFromFile_EmptyViews(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workflowContent := `views: []
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatalf("write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected 0 plugins for empty views, got %d", len(plugins))
|
||||
}
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected 0 errors for empty views, got %d", len(errs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPluginsFromFile_DokiConfigIndex(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
workflowContent := `views:
|
||||
- name: Board
|
||||
key: "B"
|
||||
lanes:
|
||||
- name: Todo
|
||||
filter: select where status = "ready"
|
||||
- name: Docs
|
||||
key: "D"
|
||||
type: doki
|
||||
fetcher: internal
|
||||
text: "hello"
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatalf("write workflow.yaml: %v", err)
|
||||
}
|
||||
|
||||
plugins, errs := loadPluginsFromFile(workflowPath, testSchema())
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("expected no errors, got: %v", errs)
|
||||
}
|
||||
if len(plugins) != 2 {
|
||||
t.Fatalf("expected 2 plugins, got %d", len(plugins))
|
||||
}
|
||||
|
||||
// verify DokiPlugin has correct ConfigIndex
|
||||
dp, ok := plugins[1].(*DokiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected DokiPlugin, got %T", plugins[1])
|
||||
}
|
||||
if dp.ConfigIndex != 1 {
|
||||
t.Errorf("expected DokiPlugin ConfigIndex 1, got %d", dp.ConfigIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePluginLists(t *testing.T) {
|
||||
base := []Plugin{
|
||||
&TikiPlugin{BasePlugin: BasePlugin{Name: "Board", FilePath: "base.yaml"}},
|
||||
&TikiPlugin{BasePlugin: BasePlugin{Name: "Bugs", FilePath: "base.yaml"}},
|
||||
}
|
||||
overrides := []Plugin{
|
||||
&TikiPlugin{BasePlugin: BasePlugin{Name: "Board", FilePath: "override.yaml"}},
|
||||
&TikiPlugin{BasePlugin: BasePlugin{Name: "NewView", FilePath: "override.yaml"}},
|
||||
}
|
||||
|
||||
result := mergePluginLists(base, overrides)
|
||||
|
||||
// Bugs (non-overridden) + Board (merged) + NewView (new)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 plugins, got %d", len(result))
|
||||
}
|
||||
|
||||
names := make([]string, len(result))
|
||||
for i, p := range result {
|
||||
names[i] = p.GetName()
|
||||
}
|
||||
|
||||
// Bugs should come first (non-overridden base), then Board (merged), then NewView (new)
|
||||
if names[0] != "Bugs" {
|
||||
t.Errorf("expected first plugin 'Bugs', got %q", names[0])
|
||||
}
|
||||
if names[1] != "Board" {
|
||||
t.Errorf("expected second plugin 'Board', got %q", names[1])
|
||||
}
|
||||
if names[2] != "NewView" {
|
||||
t.Errorf("expected third plugin 'NewView', got %q", names[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPlugin_MultipleDefaults(t *testing.T) {
|
||||
plugins := []Plugin{
|
||||
&TikiPlugin{BasePlugin: BasePlugin{Name: "A"}},
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@ type pluginFileConfig struct {
|
|||
Description string `yaml:"description"` // short description shown in header info
|
||||
Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color
|
||||
Background string `yaml:"background"`
|
||||
Key string `yaml:"key"` // single character
|
||||
Filter string `yaml:"filter"`
|
||||
Sort string `yaml:"sort"`
|
||||
Key string `yaml:"key"` // single character
|
||||
Sort string `yaml:"sort"` // deprecated: only for deserializing old configs; converted to order-by and cleared by LegacyConfigTransformer
|
||||
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
|
||||
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
|
||||
Fetcher string `yaml:"fetcher"`
|
||||
|
|
@ -46,7 +45,6 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
|
|||
Default: baseTiki.Default,
|
||||
},
|
||||
Lanes: baseTiki.Lanes,
|
||||
Sort: baseTiki.Sort,
|
||||
ViewMode: baseTiki.ViewMode,
|
||||
Actions: baseTiki.Actions,
|
||||
}
|
||||
|
|
@ -69,9 +67,6 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
|
|||
if len(overrideTiki.Lanes) > 0 {
|
||||
result.Lanes = overrideTiki.Lanes
|
||||
}
|
||||
if overrideTiki.Sort != nil {
|
||||
result.Sort = overrideTiki.Sort
|
||||
}
|
||||
if overrideTiki.ViewMode != "" {
|
||||
result.ViewMode = overrideTiki.ViewMode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,24 @@ import (
|
|||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// mustParseFilter is a test helper that parses a ruki select statement or panics.
|
||||
func mustParseFilter(t *testing.T, expr string) *ruki.ValidatedStatement {
|
||||
t.Helper()
|
||||
schema := rukiRuntime.NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
stmt, err := parser.ParseAndValidateStatement(expr, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse ruki statement %q: %v", expr, err)
|
||||
}
|
||||
return stmt
|
||||
}
|
||||
|
||||
func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
|
||||
baseFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
baseSort, _ := ParseSort("Priority")
|
||||
baseFilter := mustParseFilter(t, `select where status = "ready"`)
|
||||
|
||||
base := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
|
|
@ -25,11 +37,10 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
|
|||
Lanes: []TikiLane{
|
||||
{Name: "Todo", Columns: 1, Filter: baseFilter},
|
||||
},
|
||||
Sort: baseSort,
|
||||
ViewMode: "compact",
|
||||
}
|
||||
|
||||
overrideFilter, _ := filter.ParseFilter("type = 'bug'")
|
||||
overrideFilter := mustParseFilter(t, `select where type = "bug"`)
|
||||
override := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
Name: "Base",
|
||||
|
|
@ -45,49 +56,42 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
|
|||
Lanes: []TikiLane{
|
||||
{Name: "Bugs", Columns: 1, Filter: overrideFilter},
|
||||
},
|
||||
Sort: nil,
|
||||
ViewMode: "expanded",
|
||||
}
|
||||
|
||||
result := mergePluginDefinitions(base, override)
|
||||
resultTiki, ok := result.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatal("Expected result to be *TikiPlugin")
|
||||
t.Fatal("expected result to be *TikiPlugin")
|
||||
}
|
||||
|
||||
// Check overridden values
|
||||
// check overridden values
|
||||
if resultTiki.Rune != 'O' {
|
||||
t.Errorf("Expected rune 'O', got %q", resultTiki.Rune)
|
||||
t.Errorf("expected rune 'O', got %q", resultTiki.Rune)
|
||||
}
|
||||
if resultTiki.Modifier != tcell.ModAlt {
|
||||
t.Errorf("Expected ModAlt, got %v", resultTiki.Modifier)
|
||||
t.Errorf("expected ModAlt, got %v", resultTiki.Modifier)
|
||||
}
|
||||
if resultTiki.Foreground != tcell.ColorGreen {
|
||||
t.Errorf("Expected green foreground, got %v", resultTiki.Foreground)
|
||||
t.Errorf("expected green foreground, got %v", resultTiki.Foreground)
|
||||
}
|
||||
if resultTiki.ViewMode != "expanded" {
|
||||
t.Errorf("Expected expanded view, got %q", resultTiki.ViewMode)
|
||||
t.Errorf("expected expanded view, got %q", resultTiki.ViewMode)
|
||||
}
|
||||
if len(resultTiki.Lanes) != 1 || resultTiki.Lanes[0].Filter == nil {
|
||||
t.Error("Expected lane filter to be overridden")
|
||||
}
|
||||
|
||||
// Check that base sort is kept when override has nil
|
||||
if resultTiki.Sort == nil {
|
||||
t.Error("Expected base sort to be retained")
|
||||
t.Error("expected lane filter to be overridden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
|
||||
// This test verifies the bug fix where Modifier was not being copied from base
|
||||
baseFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
baseFilter := mustParseFilter(t, `select where status = "ready"`)
|
||||
|
||||
base := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
Name: "Base",
|
||||
Key: tcell.KeyRune,
|
||||
Rune: 'M',
|
||||
Modifier: tcell.ModAlt, // This should be preserved
|
||||
Modifier: tcell.ModAlt, // this should be preserved
|
||||
Foreground: tcell.ColorWhite,
|
||||
Background: tcell.ColorDefault,
|
||||
Type: "tiki",
|
||||
|
|
@ -97,7 +101,7 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
// Override with no modifier change (Modifier: 0)
|
||||
// override with no modifier change (Modifier: 0)
|
||||
override := &TikiPlugin{
|
||||
BasePlugin: BasePlugin{
|
||||
Name: "Base",
|
||||
|
|
@ -110,14 +114,14 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
|
|||
result := mergePluginDefinitions(base, override)
|
||||
resultTiki, ok := result.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatal("Expected result to be *TikiPlugin")
|
||||
t.Fatal("expected result to be *TikiPlugin")
|
||||
}
|
||||
|
||||
// The Modifier from base should be preserved
|
||||
// the Modifier from base should be preserved
|
||||
if resultTiki.Modifier != tcell.ModAlt {
|
||||
t.Errorf("Expected ModAlt to be preserved from base, got %v", resultTiki.Modifier)
|
||||
t.Errorf("expected ModAlt to be preserved from base, got %v", resultTiki.Modifier)
|
||||
}
|
||||
if resultTiki.Rune != 'M' {
|
||||
t.Errorf("Expected rune 'M' to be preserved from base, got %q", resultTiki.Rune)
|
||||
t.Errorf("expected rune 'M' to be preserved from base, got %q", resultTiki.Rune)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ import (
|
|||
"github.com/gdamore/tcell/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
// parsePluginConfig parses a pluginFileConfig into a Plugin
|
||||
func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
||||
func parsePluginConfig(cfg pluginFileConfig, source string, schema ruki.Schema) (Plugin, error) {
|
||||
if cfg.Name == "" {
|
||||
return nil, fmt.Errorf("plugin must have a name (%s)", source)
|
||||
}
|
||||
|
|
@ -51,12 +50,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
switch pluginType {
|
||||
case "doki":
|
||||
// Strict validation for Doki
|
||||
if cfg.Filter != "" {
|
||||
return nil, fmt.Errorf("doki plugin cannot have 'filter'")
|
||||
}
|
||||
if cfg.Sort != "" {
|
||||
return nil, fmt.Errorf("doki plugin cannot have 'sort'")
|
||||
}
|
||||
if cfg.View != "" {
|
||||
return nil, fmt.Errorf("doki plugin cannot have 'view'")
|
||||
}
|
||||
|
|
@ -95,9 +88,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
if cfg.URL != "" {
|
||||
return nil, fmt.Errorf("tiki plugin cannot have 'url'")
|
||||
}
|
||||
if cfg.Filter != "" {
|
||||
return nil, fmt.Errorf("tiki plugin cannot have 'filter'")
|
||||
}
|
||||
if len(cfg.Lanes) == 0 {
|
||||
return nil, fmt.Errorf("tiki plugin requires 'lanes'")
|
||||
}
|
||||
|
|
@ -105,6 +95,8 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
return nil, fmt.Errorf("tiki plugin has too many lanes (%d), max is 10", len(cfg.Lanes))
|
||||
}
|
||||
|
||||
parser := ruki.NewParser(schema)
|
||||
|
||||
lanes := make([]TikiLane, 0, len(cfg.Lanes))
|
||||
for i, lane := range cfg.Lanes {
|
||||
if lane.Name == "" {
|
||||
|
|
@ -120,26 +112,35 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
if lane.Width < 0 || lane.Width > 100 {
|
||||
return nil, fmt.Errorf("lane %q has invalid width %d (must be 0-100)", lane.Name, lane.Width)
|
||||
}
|
||||
filterExpr, err := filter.ParseFilter(lane.Filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
|
||||
}
|
||||
if filterExpr != nil {
|
||||
reg := config.GetStatusRegistry()
|
||||
if err := filter.ValidateFilterStatuses(filterExpr, reg.IsValid); err != nil {
|
||||
return nil, fmt.Errorf("view %q, lane %q: %w", cfg.Name, lane.Name, err)
|
||||
|
||||
var filterStmt *ruki.ValidatedStatement
|
||||
if lane.Filter != "" {
|
||||
filterStmt, err = parser.ParseAndValidateStatement(lane.Filter, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
|
||||
}
|
||||
if !filterStmt.IsSelect() {
|
||||
return nil, fmt.Errorf("lane %q filter must be a SELECT statement", lane.Name)
|
||||
}
|
||||
}
|
||||
action, err := ParseLaneAction(lane.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
|
||||
|
||||
var actionStmt *ruki.ValidatedStatement
|
||||
if lane.Action != "" {
|
||||
actionStmt, err = parser.ParseAndValidateStatement(lane.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
|
||||
}
|
||||
if !actionStmt.IsUpdate() {
|
||||
return nil, fmt.Errorf("lane %q action must be an UPDATE statement", lane.Name)
|
||||
}
|
||||
}
|
||||
|
||||
lanes = append(lanes, TikiLane{
|
||||
Name: lane.Name,
|
||||
Columns: columns,
|
||||
Width: lane.Width,
|
||||
Filter: filterExpr,
|
||||
Action: action,
|
||||
Filter: filterStmt,
|
||||
Action: actionStmt,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -152,14 +153,8 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
slog.Warn("lane widths sum exceeds 100%", "plugin", cfg.Name, "sum", widthSum)
|
||||
}
|
||||
|
||||
// Parse sort rules
|
||||
sortRules, err := ParseSort(cfg.Sort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing sort: %w", err)
|
||||
}
|
||||
|
||||
// Parse plugin actions
|
||||
actions, err := parsePluginActions(cfg.Actions)
|
||||
actions, err := parsePluginActions(cfg.Actions, parser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %q (%s): %w", cfg.Name, source, err)
|
||||
}
|
||||
|
|
@ -167,7 +162,6 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
return &TikiPlugin{
|
||||
BasePlugin: base,
|
||||
Lanes: lanes,
|
||||
Sort: sortRules,
|
||||
ViewMode: cfg.View,
|
||||
Actions: actions,
|
||||
}, nil
|
||||
|
|
@ -178,7 +172,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
}
|
||||
|
||||
// parsePluginActions parses and validates plugin action configs into PluginAction slice.
|
||||
func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
|
||||
func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]PluginAction, error) {
|
||||
if len(configs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -212,18 +206,18 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
|
|||
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
|
||||
}
|
||||
|
||||
action, err := ParseLaneAction(cfg.Action)
|
||||
actionStmt, err := parser.ParseAndValidateStatement(cfg.Action, ruki.ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
if len(action.Ops) == 0 {
|
||||
return nil, fmt.Errorf("action %d (key %q) has empty action expression", i, cfg.Key)
|
||||
if actionStmt.IsSelect() {
|
||||
return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, or DELETE — not SELECT", i, cfg.Key)
|
||||
}
|
||||
|
||||
actions = append(actions, PluginAction{
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: action,
|
||||
Action: actionStmt,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -231,11 +225,11 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
|
|||
}
|
||||
|
||||
// parsePluginYAML parses plugin YAML data into a Plugin
|
||||
func parsePluginYAML(data []byte, source string) (Plugin, error) {
|
||||
func parsePluginYAML(data []byte, source string, schema ruki.Schema) (Plugin, error) {
|
||||
var cfg pluginFileConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing yaml: %w", err)
|
||||
}
|
||||
|
||||
return parsePluginConfig(cfg, source)
|
||||
return parsePluginConfig(cfg, source, schema)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,22 @@ package plugin
|
|||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
)
|
||||
|
||||
func testSchema() ruki.Schema {
|
||||
return rukiRuntime.NewSchema()
|
||||
}
|
||||
|
||||
func testParser() *ruki.Parser {
|
||||
return ruki.NewParser(testSchema())
|
||||
}
|
||||
|
||||
func TestDokiValidation(t *testing.T) {
|
||||
schema := testSchema()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg pluginFileConfig
|
||||
|
|
@ -46,17 +59,6 @@ func TestDokiValidation(t *testing.T) {
|
|||
},
|
||||
wantError: "doki plugin with internal fetcher requires 'text'",
|
||||
},
|
||||
{
|
||||
name: "Doki with Tiki fields",
|
||||
cfg: pluginFileConfig{
|
||||
Name: "Doki with Filter",
|
||||
Type: "doki",
|
||||
Fetcher: "internal",
|
||||
Text: "ok",
|
||||
Filter: "status='ready'",
|
||||
},
|
||||
wantError: "doki plugin cannot have 'filter'",
|
||||
},
|
||||
{
|
||||
name: "Valid File Fetcher",
|
||||
cfg: pluginFileConfig{
|
||||
|
|
@ -81,7 +83,7 @@ func TestDokiValidation(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parsePluginConfig(tc.cfg, "test")
|
||||
_, err := parsePluginConfig(tc.cfg, "test", schema)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
|
||||
|
|
@ -98,6 +100,8 @@ func TestDokiValidation(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTikiValidation(t *testing.T) {
|
||||
schema := testSchema()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg pluginFileConfig
|
||||
|
|
@ -108,18 +112,22 @@ func TestTikiValidation(t *testing.T) {
|
|||
cfg: pluginFileConfig{
|
||||
Name: "Tiki with Fetcher",
|
||||
Type: "tiki",
|
||||
Filter: "status='ready'",
|
||||
Fetcher: "file",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
},
|
||||
wantError: "tiki plugin cannot have 'fetcher'",
|
||||
},
|
||||
{
|
||||
name: "Tiki with Doki fields (Text)",
|
||||
cfg: pluginFileConfig{
|
||||
Name: "Tiki with Text",
|
||||
Type: "tiki",
|
||||
Filter: "status='ready'",
|
||||
Text: "text",
|
||||
Name: "Tiki with Text",
|
||||
Type: "tiki",
|
||||
Text: "text",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
},
|
||||
wantError: "tiki plugin cannot have 'text'",
|
||||
},
|
||||
|
|
@ -127,7 +135,7 @@ func TestTikiValidation(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parsePluginConfig(tc.cfg, "test")
|
||||
_, err := parsePluginConfig(tc.cfg, "test", schema)
|
||||
if tc.wantError != "" {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
|
||||
|
|
@ -150,7 +158,7 @@ func TestParsePluginConfig_InvalidKey(t *testing.T) {
|
|||
Type: "tiki",
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid key format")
|
||||
}
|
||||
|
|
@ -165,12 +173,12 @@ func TestParsePluginConfig_DefaultTikiType(t *testing.T) {
|
|||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: "status='ready'"},
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
// Type not specified, should default to "tiki"
|
||||
}
|
||||
|
||||
plugin, err := parsePluginConfig(cfg, "test.yaml")
|
||||
plugin, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -187,7 +195,7 @@ func TestParsePluginConfig_UnknownType(t *testing.T) {
|
|||
Type: "unknown",
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unknown plugin type")
|
||||
}
|
||||
|
|
@ -198,47 +206,9 @@ func TestParsePluginConfig_UnknownType(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Type: "tiki",
|
||||
Filter: "invalid ( filter",
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid top-level filter")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "tiki plugin cannot have 'filter'") {
|
||||
t.Errorf("Expected 'cannot have filter' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePluginConfig_TikiWithInvalidSort removed - the sort parser is very lenient
|
||||
// and accepts most field names. Invalid syntax would be caught by ParseSort internally.
|
||||
|
||||
func TestParsePluginConfig_DokiWithSort(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Type: "doki",
|
||||
Fetcher: "internal",
|
||||
Text: "content",
|
||||
Sort: "Priority", // Doki shouldn't have sort
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for doki with sort field")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "doki plugin cannot have 'sort'") {
|
||||
t.Errorf("Expected 'cannot have sort' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_DokiWithView(t *testing.T) {
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
|
|
@ -249,7 +219,7 @@ func TestParsePluginConfig_DokiWithView(t *testing.T) {
|
|||
View: "expanded", // Doki shouldn't have view
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for doki with view field")
|
||||
}
|
||||
|
|
@ -262,7 +232,7 @@ func TestParsePluginConfig_DokiWithView(t *testing.T) {
|
|||
func TestParsePluginYAML_InvalidYAML(t *testing.T) {
|
||||
invalidYAML := []byte("invalid: yaml: content:")
|
||||
|
||||
_, err := parsePluginYAML(invalidYAML, "test.yaml")
|
||||
_, err := parsePluginYAML(invalidYAML, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid YAML")
|
||||
}
|
||||
|
|
@ -280,14 +250,13 @@ type: tiki
|
|||
lanes:
|
||||
- name: Todo
|
||||
columns: 4
|
||||
filter: status = 'ready'
|
||||
sort: Priority
|
||||
filter: select where status = "ready"
|
||||
view: expanded
|
||||
foreground: "#ff0000"
|
||||
background: "#0000ff"
|
||||
`)
|
||||
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml")
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
@ -315,12 +284,14 @@ background: "#0000ff"
|
|||
}
|
||||
|
||||
func TestParsePluginActions_Valid(t *testing.T) {
|
||||
parser := testParser()
|
||||
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Add to board", Action: "status = 'ready'"},
|
||||
{Key: "a", Label: "Assign to me", Action: "assignee = CURRENT_USER"},
|
||||
{Key: "b", Label: "Add to board", Action: `update where id = id() set status="ready"`},
|
||||
{Key: "a", Label: "Assign to me", Action: `update where id = id() set assignee=user()`},
|
||||
}
|
||||
|
||||
actions, err := parsePluginActions(configs)
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -335,23 +306,28 @@ func TestParsePluginActions_Valid(t *testing.T) {
|
|||
if actions[0].Label != "Add to board" {
|
||||
t.Errorf("expected label 'Add to board', got %q", actions[0].Label)
|
||||
}
|
||||
if len(actions[0].Action.Ops) != 1 {
|
||||
t.Fatalf("expected 1 op, got %d", len(actions[0].Action.Ops))
|
||||
if actions[0].Action == nil {
|
||||
t.Fatal("expected non-nil action")
|
||||
}
|
||||
if actions[0].Action.Ops[0].Field != ActionFieldStatus {
|
||||
t.Errorf("expected status field, got %v", actions[0].Action.Ops[0].Field)
|
||||
}
|
||||
if actions[0].Action.Ops[0].StrValue != "ready" {
|
||||
t.Errorf("expected 'ready', got %q", actions[0].Action.Ops[0].StrValue)
|
||||
if !actions[0].Action.IsUpdate() {
|
||||
t.Error("expected action to be an UPDATE statement")
|
||||
}
|
||||
|
||||
if actions[1].Rune != 'a' {
|
||||
t.Errorf("expected rune 'a', got %q", actions[1].Rune)
|
||||
}
|
||||
if actions[1].Action == nil {
|
||||
t.Fatal("expected non-nil action for 'assign to me'")
|
||||
}
|
||||
if !actions[1].Action.IsUpdate() {
|
||||
t.Error("expected 'assign to me' action to be an UPDATE statement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_Empty(t *testing.T) {
|
||||
actions, err := parsePluginActions(nil)
|
||||
parser := testParser()
|
||||
|
||||
actions, err := parsePluginActions(nil, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -361,6 +337,8 @@ func TestParsePluginActions_Empty(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParsePluginActions_Errors(t *testing.T) {
|
||||
parser := testParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configs []PluginActionConfig
|
||||
|
|
@ -368,17 +346,17 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "missing key",
|
||||
configs: []PluginActionConfig{{Key: "", Label: "Test", Action: "status=ready"}},
|
||||
configs: []PluginActionConfig{{Key: "", Label: "Test", Action: `update where id = id() set status="ready"`}},
|
||||
wantErr: "missing 'key'",
|
||||
},
|
||||
{
|
||||
name: "multi-character key",
|
||||
configs: []PluginActionConfig{{Key: "ab", Label: "Test", Action: "status=ready"}},
|
||||
configs: []PluginActionConfig{{Key: "ab", Label: "Test", Action: `update where id = id() set status="ready"`}},
|
||||
wantErr: "single character",
|
||||
},
|
||||
{
|
||||
name: "missing label",
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "", Action: "status=ready"}},
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "", Action: `update where id = id() set status="ready"`}},
|
||||
wantErr: "missing 'label'",
|
||||
},
|
||||
{
|
||||
|
|
@ -388,14 +366,14 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "invalid action expression",
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "Test", Action: "owner=me"}},
|
||||
wantErr: "unknown action field",
|
||||
configs: []PluginActionConfig{{Key: "b", Label: "Test", Action: `update where id = id() set owner="me"`}},
|
||||
wantErr: "parsing action",
|
||||
},
|
||||
{
|
||||
name: "duplicate key",
|
||||
configs: []PluginActionConfig{
|
||||
{Key: "b", Label: "First", Action: "status=ready"},
|
||||
{Key: "b", Label: "Second", Action: "status=done"},
|
||||
{Key: "b", Label: "First", Action: `update where id = id() set status="ready"`},
|
||||
{Key: "b", Label: "Second", Action: `update where id = id() set status="done"`},
|
||||
},
|
||||
wantErr: "duplicate action key",
|
||||
},
|
||||
|
|
@ -404,7 +382,7 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
configs: func() []PluginActionConfig {
|
||||
configs := make([]PluginActionConfig, 11)
|
||||
for i := range configs {
|
||||
configs[i] = PluginActionConfig{Key: string(rune('a' + i)), Label: "Test", Action: "status=ready"}
|
||||
configs[i] = PluginActionConfig{Key: string(rune('a' + i)), Label: "Test", Action: `update where id = id() set status="ready"`}
|
||||
}
|
||||
return configs
|
||||
}(),
|
||||
|
|
@ -414,7 +392,7 @@ func TestParsePluginActions_Errors(t *testing.T) {
|
|||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parsePluginActions(tc.configs)
|
||||
_, err := parsePluginActions(tc.configs, parser)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||
}
|
||||
|
|
@ -431,15 +409,14 @@ name: Test
|
|||
key: T
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: status = 'backlog'
|
||||
filter: select where status = "backlog"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority
|
||||
action: update where id = id() set status = "ready"
|
||||
`)
|
||||
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml")
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -469,11 +446,11 @@ func TestParsePluginConfig_DokiWithActions(t *testing.T) {
|
|||
Fetcher: "internal",
|
||||
Text: "content",
|
||||
Actions: []PluginActionConfig{
|
||||
{Key: "b", Label: "Test", Action: "status=ready"},
|
||||
{Key: "b", Label: "Test", Action: `update where id = id() set status="ready"`},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := parsePluginConfig(cfg, "test.yaml")
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", testSchema())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for doki with actions")
|
||||
}
|
||||
|
|
@ -482,6 +459,254 @@ func TestParsePluginConfig_DokiWithActions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneFilterMustBeSelect(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bad", Filter: `update where id = id() set status = "ready"`},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-SELECT filter")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "filter must be a SELECT") {
|
||||
t.Errorf("expected 'filter must be a SELECT' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneActionMustBeUpdate(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bad", Filter: `select where status = "ready"`, Action: `select where status = "done"`},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-UPDATE action")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "action must be an UPDATE") {
|
||||
t.Errorf("expected 'action must be an UPDATE' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_SelectRejectedAsAction(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")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not SELECT") {
|
||||
t.Errorf("expected 'not SELECT' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneFilterParseError(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bad", Filter: "totally invalid @@@"},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid filter expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parsing filter") {
|
||||
t.Errorf("expected 'parsing filter' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneActionParseError(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bad", Filter: `select where status = "ready"`, Action: "totally invalid @@@"},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid action expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parsing action") {
|
||||
t.Errorf("expected 'parsing action' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneMissingName(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "", Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for lane missing name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing name") {
|
||||
t.Errorf("expected 'missing name' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneInvalidColumns(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bad", Columns: -1, Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid columns")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid columns") {
|
||||
t.Errorf("expected 'invalid columns' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_LaneInvalidWidth(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Bad", Width: 101, Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid width")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid width") {
|
||||
t.Errorf("expected 'invalid width' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_TooManyLanes(t *testing.T) {
|
||||
schema := testSchema()
|
||||
lanes := make([]PluginLaneConfig, 11)
|
||||
for i := range lanes {
|
||||
lanes[i] = PluginLaneConfig{Name: "Lane", Filter: `select where status = "ready"`}
|
||||
}
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: lanes,
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too many lanes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many lanes") {
|
||||
t.Errorf("expected 'too many lanes' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_NoLanes(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Type: "tiki",
|
||||
Lanes: []PluginLaneConfig{},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no lanes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires 'lanes'") {
|
||||
t.Errorf("expected 'requires lanes' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_TikiWithURL(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Type: "tiki",
|
||||
URL: "http://example.com",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for tiki with url")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "tiki plugin cannot have 'url'") {
|
||||
t.Errorf("expected 'cannot have url' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_DokiWithLanes(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Type: "doki",
|
||||
Fetcher: "internal",
|
||||
Text: "content",
|
||||
Lanes: []PluginLaneConfig{{Name: "Bad"}},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for doki with lanes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "doki plugin cannot have 'lanes'") {
|
||||
t.Errorf("expected 'cannot have lanes' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginConfig_PluginActionsError(t *testing.T) {
|
||||
schema := testSchema()
|
||||
cfg := pluginFileConfig{
|
||||
Name: "Test",
|
||||
Key: "T",
|
||||
Lanes: []PluginLaneConfig{
|
||||
{Name: "Todo", Filter: `select where status = "ready"`},
|
||||
},
|
||||
Actions: []PluginActionConfig{
|
||||
{Key: "b", Label: "Bad", Action: "totally invalid @@@"},
|
||||
},
|
||||
}
|
||||
_, err := parsePluginConfig(cfg, "test.yaml", schema)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid plugin action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_NonPrintableKey(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "\x01", Label: "Test", Action: `update where id = id() set status="ready"`},
|
||||
}
|
||||
_, err := parsePluginActions(configs, parser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-printable key")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "printable character") {
|
||||
t.Errorf("expected 'printable character' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginYAML_ValidDoki(t *testing.T) {
|
||||
validYAML := []byte(`
|
||||
name: Doc Plugin
|
||||
|
|
@ -492,7 +717,7 @@ url: http://example.com/doc
|
|||
foreground: "#00ff00"
|
||||
`)
|
||||
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml")
|
||||
plugin, err := parsePluginYAML(validYAML, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got: %v", err)
|
||||
}
|
||||
|
|
|
|||
134
plugin/sort.go
134
plugin/sort.go
|
|
@ -1,134 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// SortRule represents a single sort criterion
|
||||
type SortRule struct {
|
||||
Field string // "Assignee", "Points", "Priority", "CreatedAt", "UpdatedAt", "Status", "Type", "Title"
|
||||
Descending bool // true for DESC, false for ASC (default)
|
||||
}
|
||||
|
||||
// ParseSort parses a sort expression like "Assignee, Points DESC, Priority DESC, CreatedAt"
|
||||
func ParseSort(expr string) ([]SortRule, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil, nil // no custom sorting
|
||||
}
|
||||
|
||||
var rules []SortRule
|
||||
parts := strings.Split(expr, ",")
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(part)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rule := SortRule{
|
||||
Field: normalizeFieldName(fields[0]),
|
||||
Descending: false,
|
||||
}
|
||||
|
||||
if len(fields) > 1 && strings.ToUpper(fields[1]) == "DESC" {
|
||||
rule.Descending = true
|
||||
}
|
||||
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// normalizeFieldName normalizes field names to a canonical form
|
||||
func normalizeFieldName(field string) string {
|
||||
switch strings.ToLower(field) {
|
||||
case "assignee":
|
||||
return "assignee"
|
||||
case "points":
|
||||
return "points"
|
||||
case "priority":
|
||||
return "priority"
|
||||
case "createdat":
|
||||
return "createdat"
|
||||
case "updatedat":
|
||||
return "updatedat"
|
||||
case "status":
|
||||
return "status"
|
||||
case "type":
|
||||
return "type"
|
||||
case "title":
|
||||
return "title"
|
||||
case "id":
|
||||
return "id"
|
||||
default:
|
||||
return strings.ToLower(field)
|
||||
}
|
||||
}
|
||||
|
||||
// SortTasks sorts tasks according to the given sort rules
|
||||
func SortTasks(tasks []*task.Task, rules []SortRule) {
|
||||
if len(rules) == 0 {
|
||||
return // preserve original order
|
||||
}
|
||||
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
for _, rule := range rules {
|
||||
cmp := compareByField(tasks[i], tasks[j], rule.Field)
|
||||
if cmp != 0 {
|
||||
if rule.Descending {
|
||||
return cmp > 0
|
||||
}
|
||||
return cmp < 0
|
||||
}
|
||||
}
|
||||
return false // equal by all criteria, preserve order
|
||||
})
|
||||
}
|
||||
|
||||
// compareByField compares two tasks by a specific field
|
||||
// Returns negative if a < b, 0 if equal, positive if a > b
|
||||
func compareByField(a, b *task.Task, field string) int {
|
||||
switch field {
|
||||
case "assignee":
|
||||
return strings.Compare(strings.ToLower(a.Assignee), strings.ToLower(b.Assignee))
|
||||
case "points":
|
||||
return a.Points - b.Points
|
||||
case "priority":
|
||||
return a.Priority - b.Priority
|
||||
case "createdat":
|
||||
if a.CreatedAt.Before(b.CreatedAt) {
|
||||
return -1
|
||||
} else if a.CreatedAt.After(b.CreatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "updatedat":
|
||||
if a.UpdatedAt.Before(b.UpdatedAt) {
|
||||
return -1
|
||||
} else if a.UpdatedAt.After(b.UpdatedAt) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "status":
|
||||
return strings.Compare(string(a.Status), string(b.Status))
|
||||
case "type":
|
||||
return strings.Compare(string(a.Type), string(b.Type))
|
||||
case "title":
|
||||
return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
|
||||
case "id":
|
||||
// Sort IDs lexicographically (alphanumeric IDs)
|
||||
return strings.Compare(strings.ToLower(a.ID), strings.ToLower(b.ID))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestSortTasks_NoRules(t *testing.T) {
|
||||
tasks := []*task.Task{
|
||||
{ID: "TIKI-C", Priority: 3},
|
||||
{ID: "TIKI-A", Priority: 1},
|
||||
{ID: "TIKI-B", Priority: 2},
|
||||
}
|
||||
SortTasks(tasks, nil)
|
||||
// original order preserved
|
||||
if tasks[0].ID != "TIKI-C" || tasks[1].ID != "TIKI-A" || tasks[2].ID != "TIKI-B" {
|
||||
t.Errorf("expected original order preserved, got %v %v %v", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortTasks_ByField(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-time.Hour)
|
||||
later := now.Add(time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tasks []*task.Task
|
||||
rules []SortRule
|
||||
expectedID []string
|
||||
}{
|
||||
{
|
||||
name: "priority ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-C", Priority: 3},
|
||||
{ID: "TIKI-A", Priority: 1},
|
||||
{ID: "TIKI-B", Priority: 2},
|
||||
},
|
||||
rules: []SortRule{{Field: "priority", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"},
|
||||
},
|
||||
{
|
||||
name: "priority DESC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-A", Priority: 1},
|
||||
{ID: "TIKI-B", Priority: 2},
|
||||
{ID: "TIKI-C", Priority: 3},
|
||||
},
|
||||
rules: []SortRule{{Field: "priority", Descending: true}},
|
||||
expectedID: []string{"TIKI-C", "TIKI-B", "TIKI-A"},
|
||||
},
|
||||
{
|
||||
name: "title ASC case-insensitive",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-Z", Title: "Zebra"},
|
||||
{ID: "TIKI-A", Title: "apple"},
|
||||
{ID: "TIKI-M", Title: "Mango"},
|
||||
},
|
||||
rules: []SortRule{{Field: "title", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"},
|
||||
},
|
||||
{
|
||||
name: "points ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-H", Points: 8},
|
||||
{ID: "TIKI-L", Points: 1},
|
||||
{ID: "TIKI-M", Points: 5},
|
||||
},
|
||||
rules: []SortRule{{Field: "points", Descending: false}},
|
||||
expectedID: []string{"TIKI-L", "TIKI-M", "TIKI-H"},
|
||||
},
|
||||
{
|
||||
name: "assignee ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-Z", Assignee: "Zara"},
|
||||
{ID: "TIKI-A", Assignee: "alice"},
|
||||
{ID: "TIKI-M", Assignee: "Bob"},
|
||||
},
|
||||
rules: []SortRule{{Field: "assignee", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-M", "TIKI-Z"},
|
||||
},
|
||||
{
|
||||
name: "status ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-R", Status: "ready"},
|
||||
{ID: "TIKI-B", Status: "backlog"},
|
||||
{ID: "TIKI-D", Status: "done"},
|
||||
},
|
||||
rules: []SortRule{{Field: "status", Descending: false}},
|
||||
expectedID: []string{"TIKI-B", "TIKI-D", "TIKI-R"},
|
||||
},
|
||||
{
|
||||
name: "type ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-S", Type: task.TypeStory},
|
||||
{ID: "TIKI-B", Type: task.TypeBug},
|
||||
},
|
||||
rules: []SortRule{{Field: "type", Descending: false}},
|
||||
expectedID: []string{"TIKI-B", "TIKI-S"},
|
||||
},
|
||||
{
|
||||
name: "id ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-C"},
|
||||
{ID: "TIKI-A"},
|
||||
{ID: "TIKI-B"},
|
||||
},
|
||||
rules: []SortRule{{Field: "id", Descending: false}},
|
||||
expectedID: []string{"TIKI-A", "TIKI-B", "TIKI-C"},
|
||||
},
|
||||
{
|
||||
name: "createdat ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-L", CreatedAt: later},
|
||||
{ID: "TIKI-E", CreatedAt: earlier},
|
||||
{ID: "TIKI-N", CreatedAt: now},
|
||||
},
|
||||
rules: []SortRule{{Field: "createdat", Descending: false}},
|
||||
expectedID: []string{"TIKI-E", "TIKI-N", "TIKI-L"},
|
||||
},
|
||||
{
|
||||
name: "updatedat DESC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-E", UpdatedAt: earlier},
|
||||
{ID: "TIKI-L", UpdatedAt: later},
|
||||
{ID: "TIKI-N", UpdatedAt: now},
|
||||
},
|
||||
rules: []SortRule{{Field: "updatedat", Descending: true}},
|
||||
expectedID: []string{"TIKI-L", "TIKI-N", "TIKI-E"},
|
||||
},
|
||||
{
|
||||
name: "multi-rule: priority ASC then title ASC",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-B2", Priority: 2, Title: "Beta"},
|
||||
{ID: "TIKI-A2", Priority: 2, Title: "Alpha"},
|
||||
{ID: "TIKI-A1", Priority: 1, Title: "Zeta"},
|
||||
},
|
||||
rules: []SortRule{
|
||||
{Field: "priority", Descending: false},
|
||||
{Field: "title", Descending: false},
|
||||
},
|
||||
expectedID: []string{"TIKI-A1", "TIKI-A2", "TIKI-B2"},
|
||||
},
|
||||
{
|
||||
name: "unknown field — equal comparison, stable order preserved",
|
||||
tasks: []*task.Task{
|
||||
{ID: "TIKI-X"},
|
||||
{ID: "TIKI-Y"},
|
||||
},
|
||||
rules: []SortRule{{Field: "nonexistent", Descending: false}},
|
||||
expectedID: []string{"TIKI-X", "TIKI-Y"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
SortTasks(tt.tasks, tt.rules)
|
||||
|
||||
if len(tt.tasks) != len(tt.expectedID) {
|
||||
t.Fatalf("task count = %d, want %d", len(tt.tasks), len(tt.expectedID))
|
||||
}
|
||||
for i, want := range tt.expectedID {
|
||||
if tt.tasks[i].ID != want {
|
||||
t.Errorf("tasks[%d].ID = %q, want %q", i, tt.tasks[i].ID, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,19 @@ func (v *ValidatedStatement) RequiresCreateTemplate() bool {
|
|||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) IsSelect() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Select != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsUpdate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Update != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsCreate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
func (v *ValidatedStatement) IsDelete() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Delete != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.statement == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "statement"}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,88 @@ func TestValidatedStatement_RequiresCreateTemplate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidatedStatement_IsSelect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vs *ValidatedStatement
|
||||
want bool
|
||||
}{
|
||||
{"nil receiver", nil, false},
|
||||
{"nil statement", &ValidatedStatement{statement: nil}, false},
|
||||
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, true},
|
||||
{"update", &ValidatedStatement{statement: &Statement{Update: &UpdateStmt{}}}, false},
|
||||
{"create", &ValidatedStatement{statement: &Statement{Create: &CreateStmt{}}}, false},
|
||||
{"delete", &ValidatedStatement{statement: &Statement{Delete: &DeleteStmt{}}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.vs.IsSelect(); got != tt.want {
|
||||
t.Errorf("IsSelect() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedStatement_IsUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vs *ValidatedStatement
|
||||
want bool
|
||||
}{
|
||||
{"nil receiver", nil, false},
|
||||
{"nil statement", &ValidatedStatement{statement: nil}, false},
|
||||
{"update", &ValidatedStatement{statement: &Statement{Update: &UpdateStmt{}}}, true},
|
||||
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.vs.IsUpdate(); got != tt.want {
|
||||
t.Errorf("IsUpdate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedStatement_IsCreate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vs *ValidatedStatement
|
||||
want bool
|
||||
}{
|
||||
{"nil receiver", nil, false},
|
||||
{"nil statement", &ValidatedStatement{statement: nil}, false},
|
||||
{"create", &ValidatedStatement{statement: &Statement{Create: &CreateStmt{}}}, true},
|
||||
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.vs.IsCreate(); got != tt.want {
|
||||
t.Errorf("IsCreate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedStatement_IsDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vs *ValidatedStatement
|
||||
want bool
|
||||
}{
|
||||
{"nil receiver", nil, false},
|
||||
{"nil statement", &ValidatedStatement{statement: nil}, false},
|
||||
{"delete", &ValidatedStatement{statement: &Statement{Delete: &DeleteStmt{}}}, true},
|
||||
{"select", &ValidatedStatement{statement: &Statement{Select: &SelectStmt{}}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.vs.IsDelete(); got != tt.want {
|
||||
t.Errorf("IsDelete() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedStatement_Accessors(t *testing.T) {
|
||||
vs := &ValidatedStatement{
|
||||
runtime: ExecutorRuntimePlugin,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/controller"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/store/tikistore"
|
||||
|
|
@ -35,6 +37,7 @@ type TestApp struct {
|
|||
PluginControllers map[string]controller.PluginControllerInterface
|
||||
PluginDefs []plugin.Plugin
|
||||
MutationGate *service.TaskMutationGate
|
||||
Schema ruki.Schema
|
||||
taskController *controller.TaskController
|
||||
statuslineConfig *model.StatuslineConfig
|
||||
headerConfig *model.HeaderConfig
|
||||
|
|
@ -61,6 +64,9 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
config.ResetPathManager()
|
||||
})
|
||||
|
||||
// 0.5. Create ruki schema (needed by plugin parser and controllers)
|
||||
schema := rukiRuntime.NewSchema()
|
||||
|
||||
// 1. Create temp dir for task files (auto-cleanup via t.TempDir())
|
||||
taskDir := t.TempDir()
|
||||
|
||||
|
|
@ -100,6 +106,7 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
taskStore,
|
||||
gate,
|
||||
statuslineConfig,
|
||||
schema,
|
||||
)
|
||||
|
||||
// 6. Initialize View Layer
|
||||
|
|
@ -166,6 +173,7 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
RootLayout: rootLayout,
|
||||
TaskStore: taskStore,
|
||||
MutationGate: gate,
|
||||
Schema: schema,
|
||||
NavController: navController,
|
||||
InputRouter: inputRouter,
|
||||
TaskDir: taskDir,
|
||||
|
|
@ -314,7 +322,7 @@ func (ta *TestApp) Cleanup() {
|
|||
// This enables testing of plugin-related functionality.
|
||||
func (ta *TestApp) LoadPlugins() error {
|
||||
// Load embedded plugins
|
||||
plugins, err := plugin.LoadPlugins()
|
||||
plugins, err := plugin.LoadPlugins(ta.Schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -338,7 +346,7 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
}
|
||||
pc.SetLaneLayout(columns, widths)
|
||||
pluginControllers[p.GetName()] = controller.NewPluginController(
|
||||
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig,
|
||||
ta.TaskStore, ta.MutationGate, pc, tp, ta.NavController, ta.statuslineConfig, ta.Schema,
|
||||
)
|
||||
} else if dp, ok := p.(*plugin.DokiPlugin); ok {
|
||||
pluginControllers[p.GetName()] = controller.NewDokiController(
|
||||
|
|
@ -373,6 +381,7 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
ta.TaskStore,
|
||||
ta.MutationGate,
|
||||
ta.statuslineConfig,
|
||||
ta.Schema,
|
||||
)
|
||||
|
||||
// Update global input capture to handle plugin switching keys
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func DrawSingleLineBorder(screen tcell.Screen, x, y, width, height int) {
|
|||
}
|
||||
|
||||
colors := config.GetColors()
|
||||
style := tcell.StyleDefault.Foreground(colors.TaskBoxUnselectedBorder).Background(config.GetContentBackgroundColor())
|
||||
style := tcell.StyleDefault.Foreground(colors.TaskBoxUnselectedBorder).Background(colors.ContentBackgroundColor)
|
||||
|
||||
DrawSingleLineBorderWithStyle(screen, x, y, width, height, style)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,17 +57,16 @@ views:
|
|||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
action: update where id = id() set status="ready"
|
||||
```
|
||||
|
||||
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.
|
||||
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
|
||||
You define the name, caption colors, hotkey, tiki filter and sorting. Save this into a `workflow.yaml` file in the config directory
|
||||
You define the name, caption colors, hotkey, and `ruki` expressions for filtering and actions. Save this into a `workflow.yaml` file in the config directory
|
||||
|
||||
Likewise the documentation is just a plugin:
|
||||
|
||||
|
|
@ -98,28 +97,27 @@ name: Custom
|
|||
foreground: "#5fff87"
|
||||
background: "#005f00"
|
||||
key: "F4"
|
||||
sort: Priority, Title
|
||||
lanes:
|
||||
- name: Ready
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: status = 'ready'
|
||||
action: status = 'ready'
|
||||
filter: select where status = "ready" order by priority, title
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: status = 'in_progress'
|
||||
action: status = 'in_progress'
|
||||
filter: select where status = "inProgress" order by priority, title
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: status = 'review'
|
||||
action: status = 'review'
|
||||
filter: select where status = "review" order by priority, title
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
filter: select where status = "done" order by priority, title
|
||||
action: update where id = id() set status="done"
|
||||
```
|
||||
|
||||
### Lane width
|
||||
|
|
@ -147,129 +145,90 @@ that apply to the currently selected tiki via a keyboard shortcut. These shortcu
|
|||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
action: update where id = id() set status="ready"
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: assignee = CURRENT_USER
|
||||
action: update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
Each action has:
|
||||
- `key` - a single printable character used as the keyboard shortcut
|
||||
- `label` - description shown in the header
|
||||
- `action` - an action expression (same syntax as lane actions, see below)
|
||||
- `action` - a `ruki` `update` statement (same syntax as lane actions, see below)
|
||||
|
||||
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.
|
||||
|
||||
### Action expression
|
||||
### ruki expressions
|
||||
|
||||
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. Here `=`
|
||||
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
|
||||
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
|
||||
Plugin filters, lane actions, and plugin actions all use the `ruki` language. Filters use `select` statements and actions use `update` statements.
|
||||
|
||||
#### Supported Fields
|
||||
#### Filter (select)
|
||||
|
||||
- `status` - set workflow status (must be a key defined in `workflow.yaml` statuses)
|
||||
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
|
||||
- `priority` - set numeric priority (1-5)
|
||||
- `points` - set numeric points (0 or positive, up to max points)
|
||||
- `assignee` - set assignee string
|
||||
- `tags` - add/remove tags (list)
|
||||
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
|
||||
|
||||
#### Operators
|
||||
```sql
|
||||
-- basic filter with sort
|
||||
select where status = "backlog" and type != "epic" order by priority, id
|
||||
|
||||
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`
|
||||
- `+=` adds tags, `-=` removes tags
|
||||
- multiple operations are separated by commas: `status=done, tags+=[moved]`
|
||||
-- recent items, most recent first
|
||||
select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
|
||||
#### Literals
|
||||
-- multiple conditions
|
||||
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
|
||||
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
|
||||
- use quotes when the value has spaces
|
||||
- integers are used for `priority` and `points`
|
||||
- tag lists use brackets: `tags += [ui, frontend]`
|
||||
- `CURRENT_USER` assigns the current git user to `assignee`
|
||||
- example: `assignee = CURRENT_USER`
|
||||
|
||||
### Filter expression
|
||||
|
||||
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
|
||||
|
||||
#### Supported Fields
|
||||
|
||||
You can filter on these task fields:
|
||||
- `id` - Task identifier (e.g., 'TIKI-m7n2xk')
|
||||
- `title` - Task title text (case-insensitive)
|
||||
- `type` - Task type: 'story', 'bug', 'spike', or 'epic' (case-insensitive)
|
||||
- `status` - Workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - Assigned user (case-insensitive)
|
||||
- `priority` - Numeric priority value
|
||||
- `points` - Story points estimate
|
||||
- `tags` (or `tag`) - List of tags (case-insensitive)
|
||||
- `createdAt` - Creation timestamp
|
||||
- `updatedAt` - Last update timestamp
|
||||
|
||||
All string comparisons are case-insensitive.
|
||||
|
||||
#### Operators
|
||||
|
||||
- **Comparison**: `=` (or `==`), `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `AND`, `OR`, `NOT` (precedence: NOT > AND > OR)
|
||||
- **Membership**: `IN`, `NOT IN` (check if value in list using `[val1, val2]`)
|
||||
- **Grouping**: Use parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and Special Values
|
||||
|
||||
**Special expressions**:
|
||||
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
|
||||
- `NOW` - Current timestamp
|
||||
|
||||
**Time expressions**:
|
||||
- `NOW - UpdatedAt` - Time elapsed since update
|
||||
- `NOW - CreatedAt` - Time since creation
|
||||
- Duration units: `min`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`, `month`/`months`
|
||||
- Examples: `2hours`, `14days`, `3weeks`, `60min`, `1month`
|
||||
- Operators: `+` (add), `-` (subtract or compute duration)
|
||||
|
||||
**Special tag semantics**:
|
||||
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
|
||||
- This allows intersection testing across tag arrays
|
||||
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Multiple statuses
|
||||
status = 'ready' OR status = 'in_progress'
|
||||
|
||||
# With tags
|
||||
tags IN ['frontend', 'urgent']
|
||||
|
||||
# High priority bugs
|
||||
type = 'bug' AND priority = 0
|
||||
|
||||
# Features and ideas assigned to me
|
||||
(type = 'feature' OR tags IN ['idea']) AND assignee = CURRENT_USER
|
||||
|
||||
# Unassigned large tasks
|
||||
assignee = '' AND points >= 5
|
||||
|
||||
# Recently created tasks not in backlog
|
||||
(NOW - CreatedAt < 2hours) AND status != 'backlog'
|
||||
-- assigned to me
|
||||
select where assignee = user() order by priority
|
||||
```
|
||||
|
||||
### Sorting
|
||||
#### Action (update)
|
||||
|
||||
The `sort` field determines the order in which tikis appear in the view. You can sort by one or more fields, and control the direction (ascending or descending).
|
||||
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
|
||||
|
||||
#### Sort Syntax
|
||||
```sql
|
||||
-- set status on move
|
||||
update where id = id() set status="ready"
|
||||
|
||||
```text
|
||||
sort: Field1, Field2 DESC, Field3
|
||||
-- set multiple fields
|
||||
update where id = id() set status="backlog" priority=2
|
||||
|
||||
-- assign to current user
|
||||
update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
#### Examples
|
||||
#### Supported fields
|
||||
|
||||
```text
|
||||
# Sort by creation time descending (recent first), then priority, then title
|
||||
sort: CreatedAt DESC, Priority, Title
|
||||
```
|
||||
- `id` - task identifier (e.g., "TIKI-M7N2XK")
|
||||
- `title` - task title text
|
||||
- `type` - task type: "story", "bug", "spike", or "epic"
|
||||
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - assigned user
|
||||
- `priority` - numeric priority value (1-5)
|
||||
- `points` - story points estimate
|
||||
- `tags` - list of tags
|
||||
- `dependsOn` - list of dependency tiki IDs
|
||||
- `due` - due date (YYYY-MM-DD format)
|
||||
- `recurrence` - recurrence pattern (cron format)
|
||||
- `createdAt` - creation timestamp
|
||||
- `updatedAt` - last update timestamp
|
||||
|
||||
#### Conditions
|
||||
|
||||
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
|
||||
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
|
||||
- **Emptiness**: `assignee is empty`, `tags is not empty`
|
||||
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
|
||||
- **Grouping**: parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and built-ins
|
||||
|
||||
- Strings: double-quoted (`"ready"`, `"alex"`)
|
||||
- Integers: `1`, `5`
|
||||
- Dates: `2026-03-25`
|
||||
- Durations: `2hour`, `14day`, `3week`, `1month`
|
||||
- Lists: `["bug", "frontend"]`
|
||||
- `user()` — current git user
|
||||
- `now()` — current timestamp
|
||||
- `id()` — currently selected tiki (in plugin context)
|
||||
- `count(select where ...)` — count matching tikis
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func NewNavigableMarkdown(cfg NavigableMarkdownConfig) *NavigableMarkdown {
|
|||
renderer = renderer.WithCodeBorder(b)
|
||||
}
|
||||
nm.viewer.SetRenderer(renderer)
|
||||
nm.viewer.SetBackgroundColor(config.GetContentBackgroundColor())
|
||||
nm.viewer.SetBackgroundColor(config.GetColors().ContentBackgroundColor)
|
||||
if cfg.ImageManager != nil && cfg.ImageManager.Supported() {
|
||||
nm.viewer.SetImageManager(cfg.ImageManager)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ func NewSearchBox() *SearchBox {
|
|||
// Configure the input field (border drawn manually in Draw)
|
||||
inputField.SetLabel("> ")
|
||||
inputField.SetLabelColor(colors.SearchBoxLabelColor)
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor)
|
||||
inputField.SetBorder(false)
|
||||
|
||||
sb := &SearchBox{
|
||||
|
|
@ -60,7 +60,7 @@ func (sb *SearchBox) Draw(screen tcell.Screen) {
|
|||
}
|
||||
|
||||
// Fill interior with theme-aware background color
|
||||
bgColor := config.GetContentBackgroundColor()
|
||||
bgColor := config.GetColors().ContentBackgroundColor
|
||||
bgStyle := tcell.StyleDefault.Background(bgColor)
|
||||
for row := y; row < y+height; row++ {
|
||||
for col := x; col < x+width; col++ {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func applyFrameStyle(frame *tview.Frame, selected bool, colors *config.ColorConf
|
|||
// buildCompactTaskContent builds the content string for compact task display
|
||||
func buildCompactTaskContent(task *taskpkg.Task, colors *config.ColorConfig, availableWidth int) string {
|
||||
emoji := taskpkg.TypeEmoji(task.Type)
|
||||
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, config.FallbackTaskIDColor)
|
||||
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, colors.FallbackTaskIDColor)
|
||||
truncatedTitle := tview.Escape(util.TruncateText(task.Title, availableWidth))
|
||||
priorityEmoji := taskpkg.PriorityLabel(task.Priority)
|
||||
pointsVisual := util.GeneratePointsVisual(task.Points, config.GetMaxPoints(), colors.PointsFilledColor, colors.PointsUnfilledColor)
|
||||
|
|
@ -45,7 +45,7 @@ func buildCompactTaskContent(task *taskpkg.Task, colors *config.ColorConfig, ava
|
|||
// buildExpandedTaskContent builds the content string for expanded task display
|
||||
func buildExpandedTaskContent(task *taskpkg.Task, colors *config.ColorConfig, availableWidth int) string {
|
||||
emoji := taskpkg.TypeEmoji(task.Type)
|
||||
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, config.FallbackTaskIDColor)
|
||||
idGradient := gradient.RenderAdaptiveGradientText(task.ID, colors.TaskBoxIDColor, colors.FallbackTaskIDColor)
|
||||
truncatedTitle := tview.Escape(util.TruncateText(task.Title, availableWidth))
|
||||
|
||||
// Extract first 3 lines of description
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ func (b *Base) assembleMetadataBox(
|
|||
|
||||
metadataBox := tview.NewFrame(metadataContainer).SetBorders(0, 0, 0, 0, 0, 0)
|
||||
metadataBox.SetBorder(true).SetTitle(
|
||||
fmt.Sprintf(" %s ", gradient.RenderAdaptiveGradientText(task.ID, colors.TaskDetailIDColor, config.FallbackTaskIDColor)),
|
||||
fmt.Sprintf(" %s ", gradient.RenderAdaptiveGradientText(task.ID, colors.TaskDetailIDColor, colors.FallbackTaskIDColor)),
|
||||
).SetBorderColor(colors.TaskBoxUnselectedBorder)
|
||||
metadataBox.SetBorderPadding(1, 0, 2, 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ func (ev *TaskEditView) ensureTagsTextArea(task *taskpkg.Task) *tview.TextArea {
|
|||
ev.tagsTextArea.SetBorder(false)
|
||||
ev.tagsTextArea.SetBorderPadding(1, 1, 2, 2)
|
||||
ev.tagsTextArea.SetPlaceholder("Enter tags separated by spaces")
|
||||
ev.tagsTextArea.SetPlaceholderStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
|
||||
ev.tagsTextArea.SetPlaceholderStyle(tcell.StyleDefault.Foreground(config.GetColors().TaskDetailPlaceholderColor))
|
||||
|
||||
ev.tagsTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyCtrlS {
|
||||
|
|
@ -448,7 +448,7 @@ func (ev *TaskEditView) ensureTitleInput(task *taskpkg.Task) *tview.InputField {
|
|||
if ev.titleInput == nil {
|
||||
colors := config.GetColors()
|
||||
ev.titleInput = tview.NewInputField()
|
||||
ev.titleInput.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
ev.titleInput.SetFieldBackgroundColor(colors.ContentBackgroundColor)
|
||||
ev.titleInput.SetFieldTextColor(colors.InputFieldTextColor)
|
||||
ev.titleInput.SetBorder(false)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue