mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
add dependsOn
This commit is contained in:
parent
a1a919e1c2
commit
98a878b6da
17 changed files with 681 additions and 52 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
55
task/depends_on.go
Normal 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
249
task/depends_on_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ func NewTaskValidator() *TaskValidator {
|
|||
&TypeValidator{},
|
||||
&PriorityValidator{},
|
||||
&PointsValidator{},
|
||||
&DependsOnValidator{},
|
||||
// Assignee and Description have no constraints (always valid)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Reference in a new issue