add dependsOn

This commit is contained in:
booleanmaybe 2026-03-12 19:49:29 -04:00
parent a1a919e1c2
commit 98a878b6da
17 changed files with 681 additions and 52 deletions

View file

@ -155,11 +155,12 @@ or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and
- `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)
#### Operators
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`
- `+=` adds tags, `-=` removes tags
- `+=` adds tags or dependencies, `-=` removes them
- multiple operations are separated by commas: `status=done, tags+=[moved]`
#### Literals
@ -186,6 +187,7 @@ You can filter on these task fields:
- `priority` - Numeric priority value
- `points` - Story points estimate
- `tags` (or `tag`) - List of tags (case-insensitive)
- `dependsOn` - List of dependency tiki IDs
- `createdAt` - Creation timestamp
- `updatedAt` - Last update timestamp

View file

@ -60,6 +60,9 @@ in Markdown format
tags:
- UX
- test
dependsOn:
- TIKI-ABC123
- TIKI-DEF456
---
This is the description of a tiki in Markdown:
@ -71,6 +74,12 @@ in Markdown format
Integration test cases
```
### dependsOn
The `dependsOn` field is a list of tiki IDs (`TIKI-XXXXXX` format) that this task depends on.
A dependency means this tiki is blocked by or requires the listed tikis.
Values must be valid tiki IDs referencing existing tikis. The field is optional and defaults to empty.
### Derived fields
Fields such as:

View file

@ -1,6 +1,6 @@
---
name: tiki
description: view, create, update, delete tikis
description: view, create, update, delete tikis and manage dependencies
allowed-tools: Read, Grep, Glob, Update, Edit, Write, WriteFile, Bash(git add:*), Bash(git rm:*)
---
@ -35,6 +35,8 @@ points: 5
tags:
- markdown
- metadata
dependsOn:
- TIKI-ABC123
---
```
@ -49,6 +51,7 @@ where fields can have these values:
- medium-low: 4
- low: 5
- points: story points from 1 to 10
- dependsOn: list of tiki IDs (TIKI-XXXXXX format) this task depends on
### body
@ -116,4 +119,51 @@ If for any reason `git rm` cannot be executed and the file is still there - dele
## Implement
When asked to implement a tiki and the user approves implementation change its status to `review` and `git add` it
When asked to implement a tiki and the user approves implementation change its status to `review` and `git add` it
## Dependencies
Tikis can declare dependencies on other tikis via the `dependsOn` frontmatter field.
Values are tiki IDs in `TIKI-XXXXXX` format. A dependency means this tiki is blocked by or requires the listed tikis.
### View dependencies
When asked about a tiki's dependencies, read its frontmatter `dependsOn` list.
For each dependency ID, read that tiki file to show its title, status, and assignee.
Highlight any dependencies that are not yet `done` -- these are blockers.
Example: "what blocks TIKI-X7F4K2?"
1. Read `.doc/tiki/tiki-x7f4k2.md` frontmatter
2. For each ID in `dependsOn`, read that tiki and report its status
### Find dependents
When asked "what depends on TIKI-ABC123?" -- grep all tiki files for that ID in their `dependsOn` field:
```
grep -l "TIKI-ABC123" .doc/tiki/*.md
```
Then read each match and check if TIKI-ABC123 appears in its `dependsOn` list.
### Add dependency
When asked to add a dependency (e.g. "TIKI-X7F4K2 depends on TIKI-ABC123"):
1. Verify the target tiki exists: check `.doc/tiki/tiki-abc123.md` exists
2. Read `.doc/tiki/tiki-x7f4k2.md`
3. Add `TIKI-ABC123` to the `dependsOn` list (create the field if missing)
4. Do not add duplicates
5. `git add` the modified file
### Remove dependency
When asked to remove a dependency:
1. Read the tiki file
2. Remove the specified ID from `dependsOn`
3. If `dependsOn` becomes empty, remove the field entirely (omitempty)
4. `git add` the modified file
### Validation
- Each value must be a valid tiki ID format: `TIKI-` followed by 6 alphanumeric characters
- Each referenced tiki must exist in `.doc/tiki/`
- Before adding, verify the target file exists; warn if it doesn't
- Circular dependencies: warn the user but do not block (e.g. A depends on B, B depends on A)

View file

@ -15,23 +15,25 @@ type LaneAction struct {
// LaneActionOp represents a single action operation.
type LaneActionOp struct {
Field ActionField
Operator ActionOperator
StrValue string
IntValue int
Tags []string
Field ActionField
Operator ActionOperator
StrValue string
IntValue int
Tags []string
DependsOn []string
}
// 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"
ActionFieldStatus ActionField = "status"
ActionFieldType ActionField = "type"
ActionFieldPriority ActionField = "priority"
ActionFieldAssignee ActionField = "assignee"
ActionFieldPoints ActionField = "points"
ActionFieldTags ActionField = "tags"
ActionFieldDependsOn ActionField = "dependsOn"
)
// ActionOperator identifies a supported action operator.
@ -80,6 +82,19 @@ func ParseLaneAction(input string) (LaneAction, error) {
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)
@ -182,6 +197,8 @@ func ApplyLaneAction(src *task.Task, action LaneAction, currentUser string) (*ta
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)
default:
return nil, fmt.Errorf("unsupported action field %q", op.Field)
}
@ -223,6 +240,8 @@ func parseActionSegment(segment string) (ActionField, ActionOperator, string, er
return ActionFieldPoints, op, value, nil
case "tags":
return ActionFieldTags, op, value, nil
case "dependson":
return ActionFieldDependsOn, op, value, nil
default:
return "", "", "", fmt.Errorf("unknown action field %q", field)
}

View file

@ -75,12 +75,12 @@ func TestSplitTopLevelCommas(t *testing.T) {
}
func TestParseLaneAction(t *testing.T) {
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review']")
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) != 6 {
t.Fatalf("expected 6 ops, got %d", len(action.Ops))
if len(action.Ops) != 7 {
t.Fatalf("expected 7 ops, got %d", len(action.Ops))
}
gotFields := []ActionField{
@ -90,6 +90,7 @@ func TestParseLaneAction(t *testing.T) {
action.Ops[3].Field,
action.Ops[4].Field,
action.Ops[5].Field,
action.Ops[6].Field,
}
wantFields := []ActionField{
ActionFieldStatus,
@ -98,6 +99,7 @@ func TestParseLaneAction(t *testing.T) {
ActionFieldPoints,
ActionFieldAssignee,
ActionFieldTags,
ActionFieldDependsOn,
}
if !reflect.DeepEqual(gotFields, wantFields) {
t.Fatalf("expected fields %v, got %v", wantFields, gotFields)
@ -121,6 +123,9 @@ func TestParseLaneAction(t *testing.T) {
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) {
@ -179,6 +184,11 @@ func TestParseLaneAction_Errors(t *testing.T) {
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 {
@ -196,17 +206,18 @@ func TestParseLaneAction_Errors(t *testing.T) {
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"},
Assignee: "Bob",
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]")
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)
}
@ -234,12 +245,18 @@ func TestApplyLaneAction(t *testing.T) {
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) {

View file

@ -71,9 +71,17 @@ func (i *InExpr) Evaluate(task *task.Task, now time.Time, currentUser string) bo
}
}
// Special handling for tags (array field)
if strings.ToLower(i.Field) == "tags" || strings.ToLower(i.Field) == "tag" {
result := evaluateTagsInComparison(task.Tags, resolvedValues)
// 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
}
@ -114,10 +122,14 @@ func (c *CompareExpr) Evaluate(task *task.Task, now time.Time, currentUser strin
compareValue = dv.Duration
}
// Handle tags specially - check if tag is in the list
if strings.ToLower(c.Field) == "tags" || strings.ToLower(c.Field) == "tag" {
// 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)
}
@ -228,6 +240,8 @@ func getTaskAttribute(task *task.Task, field string) interface{} {
return task.UpdatedAt
case "tags":
return task.Tags
case "dependson":
return task.DependsOn
case "id":
return task.ID
case "title":

View file

@ -31,6 +31,12 @@ func (s *TikiStore) CreateTask(task *taskpkg.Task) error {
task.ID = normalizeTaskID(task.ID)
// Validate dependsOn references exist
if err := s.validateDependsOnLocked(task); err != nil {
s.mu.Unlock()
return err
}
s.tasks[task.ID] = task
if err := s.saveTask(task); err != nil {
// Rollback on failure
@ -65,6 +71,12 @@ func (s *TikiStore) UpdateTask(task *taskpkg.Task) error {
return fmt.Errorf("task not found: %s", task.ID)
}
// Validate dependsOn references exist
if err := s.validateDependsOnLocked(task); err != nil {
s.mu.Unlock()
return err
}
s.tasks[task.ID] = task
if err := s.saveTask(task); err != nil {
// Rollback on failure
@ -136,3 +148,15 @@ func (s *TikiStore) AddComment(taskID string, comment taskpkg.Comment) bool {
s.notifyListeners()
return true
}
// validateDependsOnLocked checks that all dependsOn IDs reference existing tasks.
// Caller must hold s.mu lock.
func (s *TikiStore) validateDependsOnLocked(task *taskpkg.Task) error {
for _, depID := range task.DependsOn {
normalized := normalizeTaskID(depID)
if _, exists := s.tasks[normalized]; !exists {
return fmt.Errorf("dependsOn references non-existent tiki: %s", normalized)
}
}
return nil
}

View file

@ -121,6 +121,7 @@ func (s *TikiStore) loadTaskFile(path string, authorMap map[string]*git.AuthorIn
Type: taskpkg.NormalizeType(fm.Type),
Status: taskpkg.MapStatus(fm.Status),
Tags: fm.Tags.ToStringSlice(),
DependsOn: fm.DependsOn.ToStringSlice(),
Assignee: fm.Assignee,
Priority: int(fm.Priority),
Points: fm.Points,
@ -281,13 +282,14 @@ func (s *TikiStore) saveTask(task *taskpkg.Task) error {
}
fm := taskFrontmatter{
Title: task.Title,
Type: string(task.Type),
Status: taskpkg.StatusToString(task.Status),
Tags: task.Tags,
Assignee: task.Assignee,
Priority: taskpkg.PriorityValue(task.Priority),
Points: task.Points,
Title: task.Title,
Type: string(task.Type),
Status: taskpkg.StatusToString(task.Status),
Tags: task.Tags,
DependsOn: task.DependsOn,
Assignee: task.Assignee,
Priority: taskpkg.PriorityValue(task.Priority),
Points: task.Points,
}
// sort tags for consistent output
@ -295,6 +297,11 @@ func (s *TikiStore) saveTask(task *taskpkg.Task) error {
sort.Strings(fm.Tags)
}
// sort dependsOn for consistent output
if len(fm.DependsOn) > 0 {
sort.Strings(fm.DependsOn)
}
yamlBytes, err := yaml.Marshal(fm)
if err != nil {
slog.Error("failed to marshal frontmatter for task", "task_id", task.ID, "error", err)

View file

@ -36,13 +36,14 @@ type TikiStore struct {
// taskFrontmatter represents the YAML frontmatter in task files
type taskFrontmatter struct {
Title string `yaml:"title"`
Type string `yaml:"type"`
Status string `yaml:"status"`
Tags taskpkg.TagsValue `yaml:"tags,omitempty"`
Assignee string `yaml:"assignee,omitempty"`
Priority taskpkg.PriorityValue `yaml:"priority,omitempty"`
Points int `yaml:"points,omitempty"`
Title string `yaml:"title"`
Type string `yaml:"type"`
Status string `yaml:"status"`
Tags taskpkg.TagsValue `yaml:"tags,omitempty"`
DependsOn taskpkg.DependsOnValue `yaml:"dependsOn,omitempty"`
Assignee string `yaml:"assignee,omitempty"`
Priority taskpkg.PriorityValue `yaml:"priority,omitempty"`
Points int `yaml:"points,omitempty"`
}
// NewTikiStore creates a new TikiStore.

View file

@ -105,6 +105,116 @@ func TestSearch_AllTasksIncludesDescription(t *testing.T) {
}
}
}
func TestLoadTaskFile_DependsOn(t *testing.T) {
tmpDir := t.TempDir()
tests := []struct {
name string
fileContent string
expectedDependsOn []string
shouldLoad bool
}{
{
name: "valid dependsOn list",
fileContent: `---
title: Test Task
type: story
status: backlog
dependsOn:
- TIKI-ABC123
- TIKI-DEF456
---
Task description`,
expectedDependsOn: []string{"TIKI-ABC123", "TIKI-DEF456"},
shouldLoad: true,
},
{
name: "lowercase IDs uppercased",
fileContent: `---
title: Test Task
type: story
status: backlog
dependsOn:
- tiki-abc123
---
Task description`,
expectedDependsOn: []string{"TIKI-ABC123"},
shouldLoad: true,
},
{
name: "missing dependsOn field",
fileContent: `---
title: Test Task
type: story
status: backlog
---
Task description`,
expectedDependsOn: []string{},
shouldLoad: true,
},
{
name: "empty dependsOn array",
fileContent: `---
title: Test Task
type: story
status: backlog
dependsOn: []
---
Task description`,
expectedDependsOn: []string{},
shouldLoad: true,
},
{
name: "invalid dependsOn - scalar",
fileContent: `---
title: Test Task
type: story
status: backlog
dependsOn: not-a-list
---
Task description`,
expectedDependsOn: []string{},
shouldLoad: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testFile := tmpDir + "/test-task.md"
err := os.WriteFile(testFile, []byte(tt.fileContent), 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
store, storeErr := NewTikiStore(tmpDir)
if storeErr != nil {
t.Fatalf("Failed to create TikiStore: %v", storeErr)
}
task, err := store.loadTaskFile(testFile, nil, nil)
if tt.shouldLoad {
if err != nil {
t.Fatalf("loadTaskFile() unexpected error = %v", err)
}
if task == nil {
t.Fatal("loadTaskFile() returned nil task")
}
if !reflect.DeepEqual(task.DependsOn, tt.expectedDependsOn) {
t.Errorf("task.DependsOn = %v, expected %v", task.DependsOn, tt.expectedDependsOn)
}
} else {
if err == nil {
t.Error("loadTaskFile() expected error but got none")
}
}
_ = os.Remove(testFile)
})
}
}
func TestLoadTaskFile_InvalidTags(t *testing.T) {
// Create temporary directory for test files
tmpDir := t.TempDir()

View file

@ -15,13 +15,14 @@ import (
// templateFrontmatter represents the YAML frontmatter in template files
type templateFrontmatter struct {
Title string `yaml:"title"`
Type string `yaml:"type"`
Status string `yaml:"status"`
Tags []string `yaml:"tags"`
Assignee string `yaml:"assignee"`
Priority int `yaml:"priority"`
Points int `yaml:"points"`
Title string `yaml:"title"`
Type string `yaml:"type"`
Status string `yaml:"status"`
Tags []string `yaml:"tags"`
DependsOn []string `yaml:"dependsOn"`
Assignee string `yaml:"assignee"`
Priority int `yaml:"priority"`
Points int `yaml:"points"`
}
// loadTemplateTask reads new.md from user config directory, or falls back to embedded template.
@ -78,6 +79,7 @@ func parseTaskTemplate(data []byte) *taskpkg.Task {
Type: taskpkg.NormalizeType(fm.Type),
Status: taskpkg.NormalizeStatus(fm.Status),
Tags: fm.Tags,
DependsOn: fm.DependsOn,
Assignee: fm.Assignee,
Priority: fm.Priority,
Points: fm.Points,
@ -151,6 +153,7 @@ func (s *TikiStore) NewTaskTemplate() (*taskpkg.Task, error) {
task.Priority = template.Priority
task.Points = template.Points
task.Tags = template.Tags
task.DependsOn = template.DependsOn
task.Assignee = template.Assignee
task.Status = template.Status
}

55
task/depends_on.go Normal file
View file

@ -0,0 +1,55 @@
package task
import (
"log/slog"
"strings"
"gopkg.in/yaml.v3"
)
// DependsOnValue is a custom type for dependsOn that provides lenient YAML unmarshaling.
// It gracefully handles invalid YAML by defaulting to an empty slice instead of failing.
// Values are uppercased to ensure consistent TIKI-XXXXXX format.
type DependsOnValue []string
// UnmarshalYAML implements custom unmarshaling for dependsOn with lenient error handling.
// Valid YAML list formats are parsed normally. Invalid formats (scalars, objects, etc.)
// default to empty slice with a warning log instead of returning an error.
func (d *DependsOnValue) UnmarshalYAML(value *yaml.Node) error {
// Try to decode as []string (normal case)
var deps []string
if err := value.Decode(&deps); err == nil {
// Filter out empty strings, trim whitespace, and uppercase IDs
filtered := make([]string, 0, len(deps))
for _, dep := range deps {
trimmed := strings.TrimSpace(dep)
if trimmed != "" {
filtered = append(filtered, strings.ToUpper(trimmed))
}
}
*d = DependsOnValue(filtered)
return nil
}
// If decoding fails, log warning and default to empty
slog.Warn("invalid dependsOn field, defaulting to empty",
"received_type", value.Kind,
"line", value.Line,
"column", value.Column)
*d = DependsOnValue([]string{})
return nil // Don't return error - use default instead
}
// MarshalYAML implements YAML marshaling for DependsOnValue.
// Returns the underlying slice as-is for standard YAML serialization.
func (d DependsOnValue) MarshalYAML() (any, error) {
return []string(d), nil
}
// ToStringSlice converts DependsOnValue to []string for use with Task entity.
func (d DependsOnValue) ToStringSlice() []string {
if d == nil {
return []string{}
}
return []string(d)
}

249
task/depends_on_test.go Normal file
View file

@ -0,0 +1,249 @@
package task
import (
"reflect"
"testing"
"gopkg.in/yaml.v3"
)
func TestDependsOnValue_UnmarshalYAML(t *testing.T) {
tests := []struct {
name string
yaml string
expected []string
wantErr bool
}{
// Valid scenarios
{
name: "empty dependsOn (omitted)",
yaml: "other: value",
expected: []string{},
wantErr: false,
},
{
name: "empty array",
yaml: "dependsOn: []",
expected: []string{},
wantErr: false,
},
{
name: "single dependency",
yaml: "dependsOn: [TIKI-ABC123]",
expected: []string{"TIKI-ABC123"},
wantErr: false,
},
{
name: "multiple dependencies",
yaml: "dependsOn:\n - TIKI-ABC123\n - TIKI-DEF456\n - TIKI-GHI789",
expected: []string{"TIKI-ABC123", "TIKI-DEF456", "TIKI-GHI789"},
wantErr: false,
},
{
name: "lowercase IDs uppercased",
yaml: "dependsOn:\n - tiki-abc123\n - tiki-def456",
expected: []string{"TIKI-ABC123", "TIKI-DEF456"},
wantErr: false,
},
{
name: "mixed case IDs uppercased",
yaml: "dependsOn: [Tiki-Abc123]",
expected: []string{"TIKI-ABC123"},
wantErr: false,
},
{
name: "filter empty strings",
yaml: "dependsOn: [TIKI-ABC123, '', TIKI-DEF456]",
expected: []string{"TIKI-ABC123", "TIKI-DEF456"},
wantErr: false,
},
{
name: "filter whitespace-only strings",
yaml: "dependsOn: [TIKI-ABC123, ' ', TIKI-DEF456]",
expected: []string{"TIKI-ABC123", "TIKI-DEF456"},
wantErr: false,
},
// Invalid scenarios - should default to empty with no error
{
name: "scalar string instead of list",
yaml: "dependsOn: not-a-list",
expected: []string{},
wantErr: false,
},
{
name: "number instead of list",
yaml: "dependsOn: 123",
expected: []string{},
wantErr: false,
},
{
name: "boolean instead of list",
yaml: "dependsOn: true",
expected: []string{},
wantErr: false,
},
{
name: "object instead of list",
yaml: "dependsOn:\n key: value",
expected: []string{},
wantErr: false,
},
{
name: "null value",
yaml: "dependsOn: null",
expected: []string{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
DependsOn DependsOnValue `yaml:"dependsOn,omitempty"`
}
var result testStruct
err := yaml.Unmarshal([]byte(tt.yaml), &result)
if (err != nil) != tt.wantErr {
t.Errorf("UnmarshalYAML() error = %v, wantErr %v", err, tt.wantErr)
return
}
got := result.DependsOn.ToStringSlice()
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("UnmarshalYAML() got = %v, expected %v", got, tt.expected)
}
})
}
}
func TestDependsOnValue_MarshalYAML(t *testing.T) {
tests := []struct {
name string
deps DependsOnValue
expected string
}{
{
name: "empty dependsOn",
deps: DependsOnValue([]string{}),
expected: " []\n",
},
{
name: "single dependency",
deps: DependsOnValue([]string{"TIKI-ABC123"}),
expected: "\n - TIKI-ABC123\n",
},
{
name: "multiple dependencies",
deps: DependsOnValue([]string{"TIKI-ABC123", "TIKI-DEF456", "TIKI-GHI789"}),
expected: "\n - TIKI-ABC123\n - TIKI-DEF456\n - TIKI-GHI789\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
DependsOn DependsOnValue `yaml:"dependsOn"`
}
input := testStruct{DependsOn: tt.deps}
got, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
expected := "dependsOn:" + tt.expected
if string(got) != expected {
t.Errorf("MarshalYAML() got = %q, expected %q", string(got), expected)
}
})
}
}
func TestDependsOnValue_ToStringSlice(t *testing.T) {
tests := []struct {
name string
deps DependsOnValue
expected []string
}{
{
name: "nil dependsOn",
deps: nil,
expected: []string{},
},
{
name: "empty dependsOn",
deps: DependsOnValue([]string{}),
expected: []string{},
},
{
name: "single dependency",
deps: DependsOnValue([]string{"TIKI-ABC123"}),
expected: []string{"TIKI-ABC123"},
},
{
name: "multiple dependencies",
deps: DependsOnValue([]string{"TIKI-ABC123", "TIKI-DEF456"}),
expected: []string{"TIKI-ABC123", "TIKI-DEF456"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.deps.ToStringSlice()
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("ToStringSlice() got = %v, expected %v", got, tt.expected)
}
})
}
}
func TestDependsOnValue_RoundTrip(t *testing.T) {
tests := []struct {
name string
deps []string
}{
{
name: "empty dependsOn",
deps: []string{},
},
{
name: "single dependency",
deps: []string{"TIKI-ABC123"},
},
{
name: "multiple dependencies",
deps: []string{"TIKI-ABC123", "TIKI-DEF456", "TIKI-GHI789"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
DependsOn DependsOnValue `yaml:"dependsOn"`
}
// Marshal
input := testStruct{DependsOn: DependsOnValue(tt.deps)}
yamlBytes, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Unmarshal
var output testStruct
err = yaml.Unmarshal(yamlBytes, &output)
if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
// Compare
got := output.DependsOn.ToStringSlice()
if !reflect.DeepEqual(got, tt.deps) {
t.Errorf("Round trip failed: got = %v, expected %v", got, tt.deps)
}
})
}
}

View file

@ -10,6 +10,7 @@ type Task struct {
Type Type
Status Status
Tags []string
DependsOn []string
Assignee string
Priority int // lower = higher priority
Points int
@ -47,6 +48,11 @@ func (t *Task) Clone() *Task {
copy(clone.Tags, t.Tags)
}
if t.DependsOn != nil {
clone.DependsOn = make([]string, len(t.DependsOn))
copy(clone.DependsOn, t.DependsOn)
}
if t.Comments != nil {
clone.Comments = make([]Comment, len(t.Comments))
copy(clone.Comments, t.Comments)

View file

@ -24,6 +24,7 @@ func NewTaskValidator() *TaskValidator {
&TypeValidator{},
&PriorityValidator{},
&PointsValidator{},
&DependsOnValidator{},
// Assignee and Description have no constraints (always valid)
},
}

View file

@ -137,5 +137,36 @@ func (v *PointsValidator) ValidateField(task *Task) *ValidationError {
return nil
}
// DependsOnValidator validates dependsOn tiki ID format
type DependsOnValidator struct{}
func (v *DependsOnValidator) ValidateField(task *Task) *ValidationError {
for _, dep := range task.DependsOn {
if !isValidTikiIDFormat(dep) {
return &ValidationError{
Field: "dependsOn",
Value: dep,
Code: ErrCodeInvalidFormat,
Message: fmt.Sprintf("invalid tiki ID format: %s (expected TIKI-XXXXXX)", dep),
}
}
}
return nil
}
// isValidTikiIDFormat checks if a string matches the TIKI-XXXXXX format
// where X is an uppercase alphanumeric character.
func isValidTikiIDFormat(id string) bool {
if len(id) != 11 || id[:5] != "TIKI-" {
return false
}
for _, c := range id[5:] {
if (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
return false
}
}
return true
}
// AssigneeValidator - no validation needed (any string is valid)
// DescriptionValidator - no validation needed (any string is valid)

View file

@ -184,6 +184,37 @@ func TestPointsValidator(t *testing.T) {
}
}
func TestDependsOnValidator(t *testing.T) {
tests := []struct {
name string
task *Task
wantErr bool
}{
{"empty dependsOn", &Task{DependsOn: nil}, false},
{"valid single dependency", &Task{DependsOn: []string{"TIKI-ABC123"}}, false},
{"valid multiple dependencies", &Task{DependsOn: []string{"TIKI-ABC123", "TIKI-DEF456"}}, false},
{"invalid format - lowercase", &Task{DependsOn: []string{"tiki-abc123"}}, true},
{"invalid format - wrong prefix", &Task{DependsOn: []string{"TASK-ABC123"}}, true},
{"invalid format - too short", &Task{DependsOn: []string{"TIKI-ABC"}}, true},
{"invalid format - too long", &Task{DependsOn: []string{"TIKI-ABC1234"}}, true},
{"invalid format - special chars", &Task{DependsOn: []string{"TIKI-ABC12!"}}, true},
{"mixed valid and invalid", &Task{DependsOn: []string{"TIKI-ABC123", "bad-id"}}, true},
}
validator := &DependsOnValidator{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.ValidateField(tt.task)
if (err != nil) != tt.wantErr {
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
}
if err != nil && err.Code != ErrCodeInvalidFormat {
t.Errorf("expected error code: %v, got: %v", ErrCodeInvalidFormat, err.Code)
}
})
}
}
func TestTaskValidator_MultipleErrors(t *testing.T) {
// Task with multiple validation errors
task := &Task{