mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
configurable status
This commit is contained in:
parent
a6799d60e0
commit
8ff8cd578d
32 changed files with 969 additions and 145 deletions
|
|
@ -79,11 +79,33 @@ appearance:
|
|||
|
||||
### workflow.yaml
|
||||
|
||||
For detailed instructions on how to configure plugins see [Customization](plugin.md)
|
||||
For detailed instructions see [Customization](plugin.md)
|
||||
|
||||
Example `workflow.yaml` with available settings:
|
||||
Example `workflow.yaml`:
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: ready
|
||||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: review
|
||||
label: Review
|
||||
emoji: "👀"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
views:
|
||||
- name: Kanban
|
||||
foreground: "#87ceeb"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,50 @@
|
|||
# Customization
|
||||
|
||||
tiki is highly customizable. `workflow.yaml` lets you define your workflow statuses and configure views (plugins) for
|
||||
how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through,
|
||||
while plugins control what you see and how you interact with your work. This section covers both.
|
||||
|
||||
## Statuses
|
||||
|
||||
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define
|
||||
its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: ready
|
||||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: review
|
||||
label: Review
|
||||
emoji: "👀"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
```
|
||||
|
||||
Each status has:
|
||||
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI
|
||||
- `emoji` — emoji shown alongside the label
|
||||
- `active` — marks the status as "active work" (used for activity tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one status should have this)
|
||||
- `done` — marks the status as "completed" (used for completion tracking)
|
||||
|
||||
You can customize these to match your team's workflow. All filters and actions in view definitions (see below) must reference valid status keys.
|
||||
|
||||
## Plugins
|
||||
|
||||
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
|
||||
how Backlog is defined:
|
||||
|
||||
|
|
@ -20,7 +65,7 @@ views:
|
|||
sort: Priority, ID
|
||||
```
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
|
@ -40,7 +85,7 @@ views:
|
|||
that translates to - show `index.md` file located under `.doc/doki`
|
||||
installed in the same way
|
||||
|
||||
## Multi-lane plugin
|
||||
### Multi-lane plugin
|
||||
|
||||
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
|
||||
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
|
||||
|
|
@ -73,9 +118,9 @@ lanes:
|
|||
action: status = 'done'
|
||||
```
|
||||
|
||||
## Plugin actions
|
||||
### Plugin actions
|
||||
|
||||
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
|
||||
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
|
||||
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
|
||||
|
||||
```yaml
|
||||
|
|
@ -93,31 +138,31 @@ Each action has:
|
|||
- `label` - description shown in the header
|
||||
- `action` - an action expression (same syntax as lane actions, see below)
|
||||
|
||||
When the shortcut key is pressed, the action is applied to the currently selected tiki.
|
||||
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
|
||||
### Action expression
|
||||
|
||||
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
|
||||
|
||||
### Supported Fields
|
||||
#### Supported Fields
|
||||
|
||||
- `status` - set workflow status (case-insensitive)
|
||||
- `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)
|
||||
|
||||
### Operators
|
||||
#### Operators
|
||||
|
||||
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`
|
||||
- `+=` adds tags, `-=` removes tags
|
||||
- multiple operations are separated by commas: `status=done, tags+=[moved]`
|
||||
|
||||
### Literals
|
||||
#### Literals
|
||||
|
||||
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
|
||||
- use quotes when the value has spaces
|
||||
|
|
@ -126,17 +171,17 @@ or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and
|
|||
- `CURRENT_USER` assigns the current git user to `assignee`
|
||||
- example: `assignee = CURRENT_USER`
|
||||
|
||||
## Filter expression
|
||||
### Filter expression
|
||||
|
||||
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
|
||||
|
||||
### Supported Fields
|
||||
#### 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 (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
|
||||
|
|
@ -146,14 +191,14 @@ You can filter on these task fields:
|
|||
|
||||
All string comparisons are case-insensitive.
|
||||
|
||||
### Operators
|
||||
#### 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
|
||||
#### Literals and Special Values
|
||||
|
||||
**Special expressions**:
|
||||
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
|
||||
|
|
@ -170,7 +215,7 @@ All string comparisons are case-insensitive.
|
|||
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
|
||||
- This allows intersection testing across tag arrays
|
||||
|
||||
### Examples
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Multiple statuses
|
||||
|
|
@ -192,19 +237,19 @@ assignee = '' AND points >= 5
|
|||
(NOW - CreatedAt < 2hours) AND status != 'backlog'
|
||||
```
|
||||
|
||||
## Sorting
|
||||
### Sorting
|
||||
|
||||
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).
|
||||
|
||||
### Sort Syntax
|
||||
#### Sort Syntax
|
||||
|
||||
```text
|
||||
sort: Field1, Field2 DESC, Field3
|
||||
```
|
||||
|
||||
### Examples
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Sort by creation time descending (recent first), then priority, then title
|
||||
sort: CreatedAt DESC, Priority, Title
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ tags:
|
|||
|
||||
where fields can have these values:
|
||||
- type: bug, feature, task, story, epic
|
||||
- status: backlog, ready, in_progress, review, done
|
||||
- status: configurable via `workflow.yaml`. Default statuses: backlog, ready, in_progress, review, done.
|
||||
To find valid statuses for the current project, check the `statuses:` section in `~/.config/tiki/workflow.yaml`.
|
||||
- priority: is any integer number from 1 to 5 where 1 is the highest priority. Mapped to priority description:
|
||||
- high: 1
|
||||
- medium-high: 2
|
||||
|
|
@ -79,7 +80,7 @@ When asked to create a tiki:
|
|||
|
||||
- Generate a random 6-character alphanumeric ID (lowercase letters and digits)
|
||||
- The filename should be lowercase: `tiki-abc123.md`
|
||||
- If status is not specified use `backlog`
|
||||
- If status is not specified use the default status from `~/.config/tiki/workflow.yaml` (typically `backlog`)
|
||||
- If priority is not specified use 3
|
||||
- If type is not specified - prompt the user or use `story` by default
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,25 @@
|
|||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: ready
|
||||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: review
|
||||
label: Review
|
||||
emoji: "👀"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
views:
|
||||
- name: Kanban
|
||||
default: true
|
||||
|
|
|
|||
|
|
@ -151,7 +151,8 @@ func GetConfig() *Config {
|
|||
// workflowFileData represents the YAML structure of workflow.yaml for read-modify-write.
|
||||
// kept in config package to avoid import cycle with plugin package.
|
||||
type workflowFileData struct {
|
||||
Plugins []map[string]interface{} `yaml:"views"`
|
||||
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
|
||||
Plugins []map[string]interface{} `yaml:"views"`
|
||||
}
|
||||
|
||||
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.
|
||||
|
|
|
|||
225
config/statuses.go
Normal file
225
config/statuses.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// StatusDef defines a single workflow status loaded from workflow.yaml.
|
||||
type StatusDef struct {
|
||||
Key string `yaml:"key"`
|
||||
Label string `yaml:"label"`
|
||||
Emoji string `yaml:"emoji"`
|
||||
Active bool `yaml:"active"`
|
||||
Default bool `yaml:"default"`
|
||||
Done bool `yaml:"done"`
|
||||
}
|
||||
|
||||
// StatusRegistry is the central, ordered collection of valid statuses.
|
||||
// It is loaded once from workflow.yaml during bootstrap and accessed globally.
|
||||
type StatusRegistry struct {
|
||||
statuses []StatusDef
|
||||
byKey map[string]StatusDef
|
||||
defaultKey string
|
||||
doneKey string
|
||||
}
|
||||
|
||||
var (
|
||||
globalRegistry *StatusRegistry
|
||||
registryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// LoadStatusRegistry reads the statuses: section from workflow.yaml files.
|
||||
// The first file from FindWorkflowFiles() that contains a non-empty statuses list wins.
|
||||
// Returns an error if no statuses are defined anywhere (no Go fallback).
|
||||
func LoadStatusRegistry() error {
|
||||
files := FindWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no workflow.yaml found; statuses must be defined in workflow.yaml")
|
||||
}
|
||||
|
||||
for _, path := range files {
|
||||
reg, err := loadStatusesFromFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading statuses from %s: %w", path, err)
|
||||
}
|
||||
if reg != nil {
|
||||
registryMu.Lock()
|
||||
globalRegistry = reg
|
||||
registryMu.Unlock()
|
||||
slog.Debug("loaded status registry", "file", path, "count", len(reg.statuses))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
|
||||
}
|
||||
|
||||
// GetStatusRegistry returns the global StatusRegistry.
|
||||
// Panics if LoadStatusRegistry() was never called — this is a programming error,
|
||||
// not a user-facing path.
|
||||
func GetStatusRegistry() *StatusRegistry {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
if globalRegistry == nil {
|
||||
panic("config: GetStatusRegistry called before LoadStatusRegistry")
|
||||
}
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// ResetStatusRegistry replaces the global registry with one built from the given defs.
|
||||
// Intended for tests only.
|
||||
func ResetStatusRegistry(defs []StatusDef) {
|
||||
reg, err := buildRegistry(defs)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ResetStatusRegistry: %v", err))
|
||||
}
|
||||
registryMu.Lock()
|
||||
globalRegistry = reg
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearStatusRegistry removes the global registry. Intended for test teardown.
|
||||
func ClearStatusRegistry() {
|
||||
registryMu.Lock()
|
||||
globalRegistry = nil
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
// --- Registry methods ---
|
||||
|
||||
// All returns the ordered list of status definitions.
|
||||
func (r *StatusRegistry) All() []StatusDef {
|
||||
return r.statuses
|
||||
}
|
||||
|
||||
// Lookup returns the StatusDef for a given key (normalized) and whether it exists.
|
||||
func (r *StatusRegistry) Lookup(key string) (StatusDef, bool) {
|
||||
def, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return def, ok
|
||||
}
|
||||
|
||||
// IsValid reports whether key is a recognized status.
|
||||
func (r *StatusRegistry) IsValid(key string) bool {
|
||||
_, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsActive reports whether the status has the active flag set.
|
||||
func (r *StatusRegistry) IsActive(key string) bool {
|
||||
def, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return ok && def.Active
|
||||
}
|
||||
|
||||
// IsDone reports whether the status has the done flag set.
|
||||
func (r *StatusRegistry) IsDone(key string) bool {
|
||||
def, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return ok && def.Done
|
||||
}
|
||||
|
||||
// DefaultKey returns the key of the status with default: true.
|
||||
func (r *StatusRegistry) DefaultKey() string {
|
||||
return r.defaultKey
|
||||
}
|
||||
|
||||
// DoneKey returns the key of the status with done: true.
|
||||
func (r *StatusRegistry) DoneKey() string {
|
||||
return r.doneKey
|
||||
}
|
||||
|
||||
// Keys returns all status keys in definition order.
|
||||
func (r *StatusRegistry) Keys() []string {
|
||||
keys := make([]string, len(r.statuses))
|
||||
for i, s := range r.statuses {
|
||||
keys[i] = s.Key
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// NormalizeStatusKey lowercases, trims, and normalizes separators in a status key.
|
||||
func NormalizeStatusKey(key string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||
return normalized
|
||||
}
|
||||
|
||||
// --- internal ---
|
||||
|
||||
// workflowStatusData is the YAML shape we unmarshal to extract just the statuses key.
|
||||
type workflowStatusData struct {
|
||||
Statuses []StatusDef `yaml:"statuses"`
|
||||
}
|
||||
|
||||
func loadStatusesFromFile(path string) (*StatusRegistry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var ws workflowStatusData
|
||||
if err := yaml.Unmarshal(data, &ws); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
if len(ws.Statuses) == 0 {
|
||||
return nil, nil // no statuses in this file, try next
|
||||
}
|
||||
|
||||
return buildRegistry(ws.Statuses)
|
||||
}
|
||||
|
||||
func buildRegistry(defs []StatusDef) (*StatusRegistry, error) {
|
||||
if len(defs) == 0 {
|
||||
return nil, fmt.Errorf("statuses list is empty")
|
||||
}
|
||||
|
||||
reg := &StatusRegistry{
|
||||
statuses: make([]StatusDef, 0, len(defs)),
|
||||
byKey: make(map[string]StatusDef, len(defs)),
|
||||
}
|
||||
|
||||
for i, def := range defs {
|
||||
if def.Key == "" {
|
||||
return nil, fmt.Errorf("status at index %d has empty key", i)
|
||||
}
|
||||
|
||||
normalized := NormalizeStatusKey(def.Key)
|
||||
def.Key = normalized
|
||||
|
||||
if _, exists := reg.byKey[normalized]; exists {
|
||||
return nil, fmt.Errorf("duplicate status key %q", normalized)
|
||||
}
|
||||
|
||||
if def.Default {
|
||||
if reg.defaultKey != "" {
|
||||
slog.Warn("multiple statuses marked default; using first", "first", reg.defaultKey, "duplicate", normalized)
|
||||
} else {
|
||||
reg.defaultKey = normalized
|
||||
}
|
||||
}
|
||||
if def.Done {
|
||||
if reg.doneKey != "" {
|
||||
slog.Warn("multiple statuses marked done; using first", "first", reg.doneKey, "duplicate", normalized)
|
||||
} else {
|
||||
reg.doneKey = normalized
|
||||
}
|
||||
}
|
||||
|
||||
reg.byKey[normalized] = def
|
||||
reg.statuses = append(reg.statuses, def)
|
||||
}
|
||||
|
||||
// If no explicit default, use the first status
|
||||
if reg.defaultKey == "" {
|
||||
reg.defaultKey = reg.statuses[0].Key
|
||||
slog.Warn("no status marked default; using first status", "key", reg.defaultKey)
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
}
|
||||
240
config/statuses_test.go
Normal file
240
config/statuses_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func defaultTestStatuses() []StatusDef {
|
||||
return []StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestRegistry(t *testing.T, defs []StatusDef) {
|
||||
t.Helper()
|
||||
ResetStatusRegistry(defs)
|
||||
t.Cleanup(func() { ClearStatusRegistry() })
|
||||
}
|
||||
|
||||
func TestBuildRegistry_DefaultStatuses(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
if len(reg.All()) != 5 {
|
||||
t.Fatalf("expected 5 statuses, got %d", len(reg.All()))
|
||||
}
|
||||
|
||||
if reg.DefaultKey() != "backlog" {
|
||||
t.Errorf("expected default key 'backlog', got %q", reg.DefaultKey())
|
||||
}
|
||||
|
||||
if reg.DoneKey() != "done" {
|
||||
t.Errorf("expected done key 'done', got %q", reg.DoneKey())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_CustomStatuses(t *testing.T) {
|
||||
custom := []StatusDef{
|
||||
{Key: "new", Label: "New", Emoji: "🆕", Default: true},
|
||||
{Key: "wip", Label: "Work In Progress", Emoji: "🔧", Active: true},
|
||||
{Key: "closed", Label: "Closed", Emoji: "🔒", Done: true},
|
||||
}
|
||||
setupTestRegistry(t, custom)
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
if len(reg.All()) != 3 {
|
||||
t.Fatalf("expected 3 statuses, got %d", len(reg.All()))
|
||||
}
|
||||
if reg.DefaultKey() != "new" {
|
||||
t.Errorf("expected default key 'new', got %q", reg.DefaultKey())
|
||||
}
|
||||
if reg.DoneKey() != "closed" {
|
||||
t.Errorf("expected done key 'closed', got %q", reg.DoneKey())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_IsValid(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"backlog", true},
|
||||
{"ready", true},
|
||||
{"in_progress", true},
|
||||
{"In-Progress", true}, // normalization
|
||||
{"review", true},
|
||||
{"done", true},
|
||||
{"unknown", false},
|
||||
{"", false},
|
||||
{"todo", false}, // no aliases
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
if got := reg.IsValid(tt.key); got != tt.want {
|
||||
t.Errorf("IsValid(%q) = %v, want %v", tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_IsActive(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"backlog", false},
|
||||
{"ready", true},
|
||||
{"in_progress", true},
|
||||
{"review", true},
|
||||
{"done", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
if got := reg.IsActive(tt.key); got != tt.want {
|
||||
t.Errorf("IsActive(%q) = %v, want %v", tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Lookup(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
def, ok := reg.Lookup("ready")
|
||||
if !ok {
|
||||
t.Fatal("expected to find 'ready'")
|
||||
}
|
||||
if def.Label != "Ready" {
|
||||
t.Errorf("expected label 'Ready', got %q", def.Label)
|
||||
}
|
||||
if def.Emoji != "📋" {
|
||||
t.Errorf("expected emoji '📋', got %q", def.Emoji)
|
||||
}
|
||||
|
||||
_, ok = reg.Lookup("nonexistent")
|
||||
if ok {
|
||||
t.Error("expected Lookup to return false for nonexistent key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Keys(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
keys := reg.Keys()
|
||||
expected := []string{"backlog", "ready", "in_progress", "review", "done"}
|
||||
|
||||
if len(keys) != len(expected) {
|
||||
t.Fatalf("expected %d keys, got %d", len(expected), len(keys))
|
||||
}
|
||||
for i, key := range keys {
|
||||
if key != expected[i] {
|
||||
t.Errorf("keys[%d] = %q, want %q", i, key, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_NormalizesKeys(t *testing.T) {
|
||||
custom := []StatusDef{
|
||||
{Key: "In-Progress", Label: "In Progress", Default: true},
|
||||
{Key: " DONE ", Label: "Done", Done: true},
|
||||
}
|
||||
setupTestRegistry(t, custom)
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
if !reg.IsValid("in_progress") {
|
||||
t.Error("expected 'in_progress' to be valid after normalization")
|
||||
}
|
||||
if !reg.IsValid("done") {
|
||||
t.Error("expected 'done' to be valid after normalization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_EmptyKey(t *testing.T) {
|
||||
defs := []StatusDef{
|
||||
{Key: "", Label: "No Key"},
|
||||
}
|
||||
_, err := buildRegistry(defs)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_DuplicateKey(t *testing.T) {
|
||||
defs := []StatusDef{
|
||||
{Key: "ready", Label: "Ready", Default: true},
|
||||
{Key: "ready", Label: "Ready 2"},
|
||||
}
|
||||
_, err := buildRegistry(defs)
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_Empty(t *testing.T) {
|
||||
_, err := buildRegistry(nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty statuses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_DefaultFallsToFirst(t *testing.T) {
|
||||
defs := []StatusDef{
|
||||
{Key: "alpha", Label: "Alpha"},
|
||||
{Key: "beta", Label: "Beta"},
|
||||
}
|
||||
reg, err := buildRegistry(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg.DefaultKey() != "alpha" {
|
||||
t.Errorf("expected default to fall back to first status 'alpha', got %q", reg.DefaultKey())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStatusKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"backlog", "backlog"},
|
||||
{"BACKLOG", "backlog"},
|
||||
{"In-Progress", "in_progress"},
|
||||
{"in progress", "in_progress"},
|
||||
{" DONE ", "done"},
|
||||
{"In_Review", "in_review"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := NormalizeStatusKey(tt.input); got != tt.want {
|
||||
t.Errorf("NormalizeStatusKey(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_IsDone(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
if !reg.IsDone("done") {
|
||||
t.Error("expected 'done' to be marked as done")
|
||||
}
|
||||
if reg.IsDone("backlog") {
|
||||
t.Error("expected 'backlog' to not be marked as done")
|
||||
}
|
||||
}
|
||||
|
|
@ -272,13 +272,7 @@ func (tc *TaskController) SaveStatus(statusDisplay string) bool {
|
|||
var newStatus taskpkg.Status
|
||||
statusFound := false
|
||||
|
||||
for _, s := range []taskpkg.Status{
|
||||
taskpkg.StatusBacklog,
|
||||
taskpkg.StatusReady,
|
||||
taskpkg.StatusInProgress,
|
||||
taskpkg.StatusReview,
|
||||
taskpkg.StatusDone,
|
||||
} {
|
||||
for _, s := range taskpkg.AllStatuses() {
|
||||
if taskpkg.StatusDisplay(s) == statusDisplay {
|
||||
newStatus = s
|
||||
statusFound = true
|
||||
|
|
|
|||
|
|
@ -4,11 +4,23 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
}
|
||||
|
||||
// Test Draft Task Lifecycle
|
||||
|
||||
func TestTaskController_SetDraft(t *testing.T) {
|
||||
|
|
@ -120,7 +132,7 @@ func TestTaskController_SaveStatus(t *testing.T) {
|
|||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
tc.SetDraft(newTestTask())
|
||||
},
|
||||
statusDisplay: "Todo",
|
||||
statusDisplay: task.StatusDisplay(task.StatusReady),
|
||||
wantStatus: task.StatusReady,
|
||||
wantSuccess: true,
|
||||
},
|
||||
|
|
@ -131,7 +143,7 @@ func TestTaskController_SaveStatus(t *testing.T) {
|
|||
_ = s.CreateTask(t)
|
||||
tc.StartEditSession(t.ID)
|
||||
},
|
||||
statusDisplay: "In Progress",
|
||||
statusDisplay: task.StatusDisplay(task.StatusInProgress),
|
||||
wantStatus: task.StatusInProgress,
|
||||
wantSuccess: true,
|
||||
},
|
||||
|
|
@ -143,7 +155,7 @@ func TestTaskController_SaveStatus(t *testing.T) {
|
|||
tc.StartEditSession(t.ID)
|
||||
tc.SetDraft(newTestTaskWithID())
|
||||
},
|
||||
statusDisplay: "Done",
|
||||
statusDisplay: task.StatusDisplay(task.StatusDone),
|
||||
wantStatus: task.StatusDone,
|
||||
wantSuccess: true,
|
||||
},
|
||||
|
|
@ -161,7 +173,7 @@ func TestTaskController_SaveStatus(t *testing.T) {
|
|||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
// Don't set up any task
|
||||
},
|
||||
statusDisplay: "Todo",
|
||||
statusDisplay: task.StatusDisplay(task.StatusReady),
|
||||
wantSuccess: false,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package bootstrap
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
|
@ -71,6 +72,11 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
slog.Warn("failed to install default workflow", "error", err)
|
||||
}
|
||||
|
||||
// Phase 2.7: Load status definitions from workflow.yaml
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
return nil, fmt.Errorf("load status registry: %w", err)
|
||||
}
|
||||
|
||||
// Phase 3: Configuration and logging
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
|
|||
return "", fmt.Errorf("project not initialized: run 'tiki init' first")
|
||||
}
|
||||
|
||||
// Load status definitions before creating tasks
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
return "", fmt.Errorf("load status registry: %w", err)
|
||||
}
|
||||
|
||||
tikiStore, _, err := bootstrap.InitStores()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("initialize store: %w", err)
|
||||
|
|
|
|||
7
model/testinit_test.go
Normal file
7
model/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package model
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
7
plugin/filter/testinit_test.go
Normal file
7
plugin/filter/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package filter
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
51
plugin/filter/validate.go
Normal file
51
plugin/filter/validate.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
89
plugin/filter/validate_test.go
Normal file
89
plugin/filter/validate_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validStatus(key string) bool {
|
||||
valid := map[string]bool{
|
||||
"backlog": true, "ready": true, "in_progress": 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ name: Active Work
|
|||
key: A
|
||||
lanes:
|
||||
- name: Active
|
||||
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'cancelled']
|
||||
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'backlog']
|
||||
`
|
||||
|
||||
def, err := parsePluginYAML([]byte(pluginYAML), "test")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/gdamore/tcell/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
)
|
||||
|
||||
|
|
@ -118,6 +119,12 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
action, err := ParseLaneAction(lane.Action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
|
||||
|
|
|
|||
7
plugin/testinit_test.go
Normal file
7
plugin/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package plugin
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
|
|
@ -231,21 +231,22 @@ func (h *TaskHistory) recordEvents(events []statusEvent) {
|
|||
}
|
||||
|
||||
func parseStatusFromContent(content string) (task.Status, error) {
|
||||
defaultStatus := task.DefaultStatus()
|
||||
frontmatter, _, err := ParseFrontmatter(content)
|
||||
if err != nil {
|
||||
return task.StatusBacklog, err
|
||||
return defaultStatus, err
|
||||
}
|
||||
|
||||
if frontmatter == "" {
|
||||
return task.StatusBacklog, nil
|
||||
return defaultStatus, nil
|
||||
}
|
||||
|
||||
var fm map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), &fm); err != nil {
|
||||
return task.StatusBacklog, err
|
||||
return defaultStatus, err
|
||||
}
|
||||
|
||||
statusVal := task.StatusBacklog
|
||||
statusVal := defaultStatus
|
||||
if rawStatus, ok := fm["status"]; ok {
|
||||
if s, ok := rawStatus.(string); ok && s != "" {
|
||||
statusVal = task.MapStatus(s)
|
||||
|
|
@ -256,7 +257,7 @@ func parseStatusFromContent(content string) (task.Status, error) {
|
|||
}
|
||||
|
||||
func isActiveStatus(status task.Status) bool {
|
||||
return status == task.StatusReady || status == task.StatusInProgress || status == task.StatusReview
|
||||
return task.IsActiveStatus(status)
|
||||
}
|
||||
|
||||
func deriveTaskID(fileName string) string {
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ func (s *InMemoryStore) NewTaskTemplate() (*task.Task, error) {
|
|||
Title: "",
|
||||
Description: "",
|
||||
Type: task.TypeStory,
|
||||
Status: task.StatusBacklog,
|
||||
Status: task.DefaultStatus(),
|
||||
Priority: 7, // Match embedded template default
|
||||
Points: 1,
|
||||
Tags: []string{"idea"},
|
||||
|
|
|
|||
|
|
@ -3,9 +3,21 @@ package store
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -119,38 +131,28 @@ func TestMapStatus(t *testing.T) {
|
|||
// Valid statuses - exact match
|
||||
{name: "backlog", input: "backlog", expected: taskpkg.StatusBacklog},
|
||||
{name: "ready", input: "ready", expected: taskpkg.StatusReady},
|
||||
{name: "ready", input: "ready", expected: taskpkg.StatusReady},
|
||||
{name: "in_progress", input: "in_progress", expected: taskpkg.StatusInProgress},
|
||||
{name: "review", input: "review", expected: taskpkg.StatusReview},
|
||||
{name: "in_progress", input: "in_progress", expected: taskpkg.StatusInProgress},
|
||||
{name: "review", input: "review", expected: taskpkg.StatusReview},
|
||||
{name: "done", input: "done", expected: taskpkg.StatusDone},
|
||||
|
||||
// Case variations
|
||||
// Case variations (normalization still works)
|
||||
{name: "BACKLOG uppercase", input: "BACKLOG", expected: taskpkg.StatusBacklog},
|
||||
{name: "ToDo mixed case", input: "ToDo", expected: taskpkg.StatusReady},
|
||||
{name: "DONE uppercase", input: "DONE", expected: taskpkg.StatusDone},
|
||||
|
||||
// Aliases and variants
|
||||
{name: "open -> todo", input: "open", expected: taskpkg.StatusReady},
|
||||
{name: "in process -> in_progress", input: "in process", expected: taskpkg.StatusInProgress},
|
||||
{name: "closed -> done", input: "closed", expected: taskpkg.StatusDone},
|
||||
{name: "completed -> done", input: "completed", expected: taskpkg.StatusDone},
|
||||
|
||||
// in_progress variations
|
||||
// Separator normalization (hyphens/spaces → underscores)
|
||||
{name: "in-progress hyphenated", input: "in-progress", expected: taskpkg.StatusInProgress},
|
||||
{name: "inprogress no separator", input: "inprogress", expected: taskpkg.StatusInProgress},
|
||||
{name: "in progress spaces", input: "in progress", expected: taskpkg.StatusInProgress},
|
||||
{name: "In-Progress mixed case", input: "In-Progress", expected: taskpkg.StatusInProgress},
|
||||
|
||||
// review variations
|
||||
{name: "in_review", input: "in_review", expected: taskpkg.StatusReview},
|
||||
{name: "in review", input: "in review", expected: taskpkg.StatusReview},
|
||||
|
||||
// Unknown status defaults to backlog
|
||||
// Unknown status defaults to configured default (backlog)
|
||||
{name: "unknown status", input: "unknown", expected: taskpkg.StatusBacklog},
|
||||
{name: "empty string", input: "", expected: taskpkg.StatusBacklog},
|
||||
{name: "random text", input: "foobar", expected: taskpkg.StatusBacklog},
|
||||
// Aliases no longer supported — these now map to default
|
||||
{name: "todo (no alias)", input: "todo", expected: taskpkg.StatusBacklog},
|
||||
{name: "closed (no alias)", input: "closed", expected: taskpkg.StatusBacklog},
|
||||
{name: "completed (no alias)", input: "completed", expected: taskpkg.StatusBacklog},
|
||||
{name: "open (no alias)", input: "open", expected: taskpkg.StatusBacklog},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -136,9 +136,9 @@ func (s *TikiStore) NewTaskTemplate() (*taskpkg.Task, error) {
|
|||
ID: taskID,
|
||||
Title: "",
|
||||
Description: "",
|
||||
Status: taskpkg.StatusBacklog, // default fallback
|
||||
Type: taskpkg.TypeStory, // default fallback
|
||||
Priority: 3, // default: medium priority (1-5 scale)
|
||||
Status: taskpkg.DefaultStatus(), // default fallback
|
||||
Type: taskpkg.TypeStory, // default fallback
|
||||
Priority: 3, // default: medium priority (1-5 scale)
|
||||
Points: 0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ func (s *TikiStore) NewTaskTemplate() (*taskpkg.Task, error) {
|
|||
|
||||
// Ensure status has a value
|
||||
if task.Status == "" {
|
||||
task.Status = taskpkg.StatusBacklog
|
||||
task.Status = taskpkg.DefaultStatus()
|
||||
}
|
||||
|
||||
// Set git author
|
||||
|
|
|
|||
7
store/tikistore/testinit_test.go
Normal file
7
store/tikistore/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package tikistore
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
// Convenience constants matching the default workflow.yaml statuses.
|
||||
// These are kept so that tests and internal code can reference them.
|
||||
const (
|
||||
StatusBacklog Status = "backlog"
|
||||
StatusReady Status = "ready"
|
||||
|
|
@ -14,42 +16,19 @@ const (
|
|||
StatusDone Status = "done"
|
||||
)
|
||||
|
||||
type statusInfo struct {
|
||||
label string
|
||||
emoji string
|
||||
}
|
||||
|
||||
var statuses = map[Status]statusInfo{
|
||||
StatusBacklog: {label: "Backlog", emoji: "📥"},
|
||||
StatusReady: {label: "Ready", emoji: "📋"},
|
||||
StatusInProgress: {label: "In Progress", emoji: "⚙️"},
|
||||
StatusReview: {label: "Review", emoji: "👀"},
|
||||
StatusDone: {label: "Done", emoji: "✅"},
|
||||
}
|
||||
|
||||
func normalizeStatusKey(status string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(status))
|
||||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ParseStatus normalizes a raw status string and validates it against the registry.
|
||||
// Empty input returns the configured default status.
|
||||
// Unknown values return (DefaultStatus(), false).
|
||||
func ParseStatus(status string) (Status, bool) {
|
||||
normalized := normalizeStatusKey(status)
|
||||
switch normalized {
|
||||
case "", "backlog":
|
||||
return StatusBacklog, true
|
||||
case "ready", "todo", "to_do", "open":
|
||||
return StatusReady, true
|
||||
case "in_progress", "inprocess", "in_process", "inprogress":
|
||||
return StatusInProgress, true
|
||||
case "review", "in_review", "inreview":
|
||||
return StatusReview, true
|
||||
case "done", "closed", "completed":
|
||||
return StatusDone, true
|
||||
default:
|
||||
return StatusBacklog, false
|
||||
normalized := config.NormalizeStatusKey(status)
|
||||
if normalized == "" {
|
||||
return DefaultStatus(), true
|
||||
}
|
||||
reg := config.GetStatusRegistry()
|
||||
if reg.IsValid(normalized) {
|
||||
return Status(normalized), true
|
||||
}
|
||||
return DefaultStatus(), false
|
||||
}
|
||||
|
||||
// NormalizeStatus standardizes a raw status string into a Status.
|
||||
|
|
@ -65,27 +44,32 @@ func MapStatus(status string) Status {
|
|||
|
||||
// StatusToString converts a Status to its string representation.
|
||||
func StatusToString(status Status) string {
|
||||
if _, ok := statuses[status]; ok {
|
||||
reg := config.GetStatusRegistry()
|
||||
if reg.IsValid(string(status)) {
|
||||
return string(status)
|
||||
}
|
||||
return string(StatusBacklog)
|
||||
return reg.DefaultKey()
|
||||
}
|
||||
|
||||
// StatusEmoji returns the emoji for a status from the registry.
|
||||
func StatusEmoji(status Status) string {
|
||||
if info, ok := statuses[status]; ok {
|
||||
return info.emoji
|
||||
reg := config.GetStatusRegistry()
|
||||
if def, ok := reg.Lookup(string(status)); ok {
|
||||
return def.Emoji
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StatusLabel returns the display label for a status from the registry.
|
||||
func StatusLabel(status Status) string {
|
||||
if info, ok := statuses[status]; ok {
|
||||
return info.label
|
||||
reg := config.GetStatusRegistry()
|
||||
if def, ok := reg.Lookup(string(status)); ok {
|
||||
return def.Label
|
||||
}
|
||||
// fall back to the raw string if unknown
|
||||
return string(status)
|
||||
}
|
||||
|
||||
// StatusDisplay returns "Label Emoji" for a status.
|
||||
func StatusDisplay(status Status) string {
|
||||
label := StatusLabel(status)
|
||||
emoji := StatusEmoji(status)
|
||||
|
|
@ -94,3 +78,29 @@ func StatusDisplay(status Status) string {
|
|||
}
|
||||
return label + " " + emoji
|
||||
}
|
||||
|
||||
// DefaultStatus returns the status configured as default in workflow.yaml.
|
||||
func DefaultStatus() Status {
|
||||
return Status(config.GetStatusRegistry().DefaultKey())
|
||||
}
|
||||
|
||||
// DoneStatus returns the status configured as done in workflow.yaml.
|
||||
func DoneStatus() Status {
|
||||
return Status(config.GetStatusRegistry().DoneKey())
|
||||
}
|
||||
|
||||
// AllStatuses returns the ordered list of all configured statuses.
|
||||
func AllStatuses() []Status {
|
||||
reg := config.GetStatusRegistry()
|
||||
defs := reg.All()
|
||||
statuses := make([]Status, len(defs))
|
||||
for i, d := range defs {
|
||||
statuses[i] = Status(d.Key)
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
// IsActiveStatus reports whether the status has the active flag set.
|
||||
func IsActiveStatus(status Status) bool {
|
||||
return config.GetStatusRegistry().IsActive(string(status))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,16 +41,8 @@ func (v *TitleValidator) ValidateField(task *Task) *ValidationError {
|
|||
type StatusValidator struct{}
|
||||
|
||||
func (v *StatusValidator) ValidateField(task *Task) *ValidationError {
|
||||
validStatuses := []Status{
|
||||
StatusBacklog,
|
||||
StatusReady,
|
||||
StatusInProgress,
|
||||
StatusReview,
|
||||
StatusDone,
|
||||
}
|
||||
|
||||
if slices.Contains(validStatuses, task.Status) {
|
||||
return nil // Valid
|
||||
if config.GetStatusRegistry().IsValid(string(task.Status)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ValidationError{
|
||||
|
|
|
|||
|
|
@ -3,8 +3,21 @@ package task
|
|||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTitleValidator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
if err := config.InstallDefaultWorkflow(); err != nil {
|
||||
t.Fatalf("failed to install default workflow for test: %v", err)
|
||||
}
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
t.Fatalf("failed to load status registry for test: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
config.ClearStatusRegistry()
|
||||
config.ResetPathManager()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,48 @@
|
|||
First of all, you just navigated to a linked file. To go back press `Left` arrow or `Alt-Left`
|
||||
To go forward press `Right` arrow or `Alt-Right`
|
||||
|
||||
tiki is highly customizable. `workflow.yaml` lets you define your workflow statuses and configure views (plugins) for how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through, while plugins control what you see and how you interact with your work. This section covers both.
|
||||
|
||||
## Statuses
|
||||
|
||||
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: ready
|
||||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: review
|
||||
label: Review
|
||||
emoji: "👀"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
```
|
||||
|
||||
Each status has:
|
||||
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI
|
||||
- `emoji` — emoji shown alongside the label
|
||||
- `active` — marks the status as "active work" (used for burndown charts and activity tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one status should have this)
|
||||
- `done` — marks the status as "completed" (used for completion tracking)
|
||||
|
||||
You can customize these to match your team's workflow. All filters and actions in view definitions must reference valid status keys.
|
||||
|
||||
## Plugins
|
||||
|
||||
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
|
||||
how Backlog is defined:
|
||||
|
||||
|
|
@ -22,11 +64,11 @@ views:
|
|||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
```
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
Likewise the documentation is just a plugin:
|
||||
|
||||
```yaml
|
||||
|
|
@ -43,7 +85,7 @@ views:
|
|||
that translates to - show `index.md` file located under `.doc/doki`
|
||||
installed in the same way
|
||||
|
||||
## Multi-lane plugin
|
||||
### Multi-lane plugin
|
||||
|
||||
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
|
||||
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
|
||||
|
|
@ -76,9 +118,9 @@ lanes:
|
|||
action: status = 'done'
|
||||
```
|
||||
|
||||
## Plugin actions
|
||||
### Plugin actions
|
||||
|
||||
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
|
||||
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
|
||||
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
|
||||
|
||||
```yaml
|
||||
|
|
@ -96,31 +138,31 @@ Each action has:
|
|||
- `label` - description shown in the header
|
||||
- `action` - an action expression (same syntax as lane actions, see below)
|
||||
|
||||
When the shortcut key is pressed, the action is applied to the currently selected tiki.
|
||||
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
|
||||
### Action expression
|
||||
|
||||
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
|
||||
|
||||
### Supported Fields
|
||||
#### Supported Fields
|
||||
|
||||
- `status` - set workflow status (case-insensitive)
|
||||
- `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)
|
||||
|
||||
### Operators
|
||||
#### Operators
|
||||
|
||||
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`
|
||||
- `+=` adds tags, `-=` removes tags
|
||||
- multiple operations are separated by commas: `status=done, tags+=[moved]`
|
||||
|
||||
### Literals
|
||||
#### Literals
|
||||
|
||||
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
|
||||
- use quotes when the value has spaces
|
||||
|
|
@ -129,17 +171,17 @@ or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and
|
|||
- `CURRENT_USER` assigns the current git user to `assignee`
|
||||
- example: `assignee = CURRENT_USER`
|
||||
|
||||
## Filter expression
|
||||
### Filter expression
|
||||
|
||||
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
|
||||
|
||||
### Supported Fields
|
||||
#### 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 (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
|
||||
|
|
@ -149,14 +191,14 @@ You can filter on these task fields:
|
|||
|
||||
All string comparisons are case-insensitive.
|
||||
|
||||
### Operators
|
||||
#### 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
|
||||
#### Literals and Special Values
|
||||
|
||||
**Special expressions**:
|
||||
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
|
||||
|
|
@ -173,7 +215,7 @@ All string comparisons are case-insensitive.
|
|||
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
|
||||
- This allows intersection testing across tag arrays
|
||||
|
||||
### Examples
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Multiple statuses
|
||||
|
|
@ -195,19 +237,19 @@ assignee = '' AND points >= 5
|
|||
(NOW - CreatedAt < 2hours) AND status != 'backlog'
|
||||
```
|
||||
|
||||
## Sorting
|
||||
### Sorting
|
||||
|
||||
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).
|
||||
|
||||
### Sort Syntax
|
||||
#### Sort Syntax
|
||||
|
||||
```text
|
||||
sort: Field1, Field2 DESC, Field3
|
||||
```
|
||||
|
||||
### Examples
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Sort by creation time descending (recent first), then priority, then title
|
||||
sort: CreatedAt DESC, Priority, Title
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ state of the repo or its git branch. Also, all past versions and deleted items r
|
|||
## Board
|
||||
|
||||
Board is a simple Kanban-style board where tikis can be moved around with `Shift-Right` and `Shift-Left`
|
||||
As tikis are moved their status changes correspondingly.
|
||||
As tikis are moved their status changes correspondingly. Statuses are configurable via `workflow.yaml`.
|
||||
Tikis can be opened for viewing or editing or searched by title and description.
|
||||
|
||||
To quickly capture an idea - hit `n` in the board or any tiki view, type in the title and press Enter
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ import (
|
|||
|
||||
func (ev *TaskEditView) ensureStatusSelectList(task *taskpkg.Task) *component.EditSelectList {
|
||||
if ev.statusSelectList == nil {
|
||||
statusOptions := []string{
|
||||
taskpkg.StatusDisplay(taskpkg.StatusBacklog),
|
||||
taskpkg.StatusDisplay(taskpkg.StatusReady),
|
||||
taskpkg.StatusDisplay(taskpkg.StatusInProgress),
|
||||
taskpkg.StatusDisplay(taskpkg.StatusReview),
|
||||
taskpkg.StatusDisplay(taskpkg.StatusDone),
|
||||
allStatuses := taskpkg.AllStatuses()
|
||||
statusOptions := make([]string, len(allStatuses))
|
||||
for i, s := range allStatuses {
|
||||
statusOptions[i] = taskpkg.StatusDisplay(s)
|
||||
}
|
||||
|
||||
colors := config.GetColors()
|
||||
|
|
|
|||
7
view/taskdetail/testinit_test.go
Normal file
7
view/taskdetail/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package taskdetail
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
7
view/testinit_test.go
Normal file
7
view/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package view
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
Loading…
Reference in a new issue