Skip to content

Commit 59e55d1

Browse files
authored
feat: add circular dependency detection (#366)
* feat:: add circular dependency detection * fix: undo task refresh change
1 parent 6e6c8ed commit 59e55d1

File tree

8 files changed

+211
-38
lines changed

8 files changed

+211
-38
lines changed

backend/controllers/add_task.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"os"
1112
)
1213

1314
var GlobalJobQueue *JobQueue
@@ -62,9 +63,27 @@ func AddTaskHandler(w http.ResponseWriter, r *http.Request) {
6263
}
6364

6465
// Validate dependencies
65-
if err := utils.ValidateDependencies(depends, ""); err != nil {
66-
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
67-
return
66+
origin := os.Getenv("CONTAINER_ORIGIN")
67+
existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid)
68+
if err != nil {
69+
if err := utils.ValidateDependencies(depends, ""); err != nil {
70+
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
71+
return
72+
}
73+
} else {
74+
taskDeps := make([]utils.TaskDependency, len(existingTasks))
75+
for i, task := range existingTasks {
76+
taskDeps[i] = utils.TaskDependency{
77+
UUID: task.UUID,
78+
Depends: task.Depends,
79+
Status: task.Status,
80+
}
81+
}
82+
83+
if err := utils.ValidateCircularDependencies(depends, "", taskDeps); err != nil {
84+
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
85+
return
86+
}
6887
}
6988
dueDateStr, err := utils.ConvertOptionalISOToTaskwarriorFormat(dueDate)
7089
if err != nil {

backend/controllers/edit_task.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"os"
1112
)
1213

1314
// EditTaskHandler godoc
@@ -62,9 +63,27 @@ func EditTaskHandler(w http.ResponseWriter, r *http.Request) {
6263
}
6364

6465
// Validate dependencies
65-
if err := utils.ValidateDependencies(depends, uuid); err != nil {
66-
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
67-
return
66+
origin := os.Getenv("CONTAINER_ORIGIN")
67+
existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid)
68+
if err != nil {
69+
if err := utils.ValidateDependencies(depends, taskUUID); err != nil {
70+
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
71+
return
72+
}
73+
} else {
74+
taskDeps := make([]utils.TaskDependency, len(existingTasks))
75+
for i, task := range existingTasks {
76+
taskDeps[i] = utils.TaskDependency{
77+
UUID: task.UUID,
78+
Depends: task.Depends,
79+
Status: task.Status,
80+
}
81+
}
82+
83+
if err := utils.ValidateCircularDependencies(depends, taskUUID, taskDeps); err != nil {
84+
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
85+
return
86+
}
6887
}
6988

7089
start, err = utils.ConvertISOToTaskwarriorFormat(start)

backend/controllers/modify_task.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"os"
1112
)
1213

1314
// ModifyTaskHandler godoc
@@ -61,9 +62,27 @@ func ModifyTaskHandler(w http.ResponseWriter, r *http.Request) {
6162
}
6263

6364
// Validate dependencies
64-
if err := utils.ValidateDependencies(depends, uuid); err != nil {
65-
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
66-
return
65+
origin := os.Getenv("CONTAINER_ORIGIN")
66+
existingTasks, err := tw.FetchTasksFromTaskwarrior(email, encryptionSecret, origin, uuid)
67+
if err != nil {
68+
if err := utils.ValidateDependencies(depends, taskUUID); err != nil {
69+
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
70+
return
71+
}
72+
} else {
73+
taskDeps := make([]utils.TaskDependency, len(existingTasks))
74+
for i, task := range existingTasks {
75+
taskDeps[i] = utils.TaskDependency{
76+
UUID: task.UUID,
77+
Depends: task.Depends,
78+
Status: task.Status,
79+
}
80+
}
81+
82+
if err := utils.ValidateCircularDependencies(depends, taskUUID, taskDeps); err != nil {
83+
http.Error(w, fmt.Sprintf("Invalid dependencies: %v", err), http.StatusBadRequest)
84+
return
85+
}
6786
}
6887

6988
// if err := tw.ModifyTaskInTaskwarrior(uuid, description, project, priority, status, due, email, encryptionSecret, taskID); err != nil {

backend/utils/datetime.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) {
1515
"2006-01-02T15:04:05.000Z", // "2025-12-27T14:30:00.000Z" (frontend datetime with milliseconds)
1616
"2006-01-02T15:04:05Z", // "2025-12-27T14:30:00Z" (datetime without milliseconds)
1717
"2006-01-02", // "2025-12-27" (date only)
18+
"20060102T150405Z", // "20260128T000000Z" (compact ISO format)
19+
"20060102", // "20260128" (compact date only)
1820
}
1921

2022
var parsedTime time.Time
@@ -24,8 +26,8 @@ func ConvertISOToTaskwarriorFormat(isoDatetime string) (string, error) {
2426
for i, format := range formats {
2527
parsedTime, err = time.Parse(format, isoDatetime)
2628
if err == nil {
27-
// Check if it's date-only format (last format in array)
28-
isDateOnly = (i == 2) // "2006-01-02" format
29+
// Check if it's date-only format
30+
isDateOnly = (i == 2 || i == 4) // "2006-01-02" or "20060102" formats
2931
break
3032
}
3133
}

backend/utils/utils_test.go

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,45 @@ func Test_ExecCommandForOutputInDir(t *testing.T) {
8585
}
8686
}
8787

88-
func Test_ValidateDependencies_ValidDependencies(t *testing.T) {
89-
depends := []string{"task-uuid-1", "task-uuid-2"}
90-
currentTaskUUID := "current-task-uuid"
91-
err := ValidateDependencies(depends, currentTaskUUID)
92-
assert.NoError(t, err)
93-
}
94-
9588
func Test_ValidateDependencies_EmptyList(t *testing.T) {
9689
depends := []string{}
9790
currentTaskUUID := "current-task-uuid"
9891
err := ValidateDependencies(depends, currentTaskUUID)
9992
assert.NoError(t, err)
10093
}
10194

95+
// Circular Dependency Detection Tests
96+
func Test_detectCycle_NoCycle(t *testing.T) { //A -> B -> C
97+
graph := map[string][]string{
98+
"A": {"B"},
99+
"B": {"C"},
100+
"C": {},
101+
}
102+
103+
hasCycle := detectCycle(graph, "A")
104+
assert.False(t, hasCycle, "Should not detect cycle in linear dependency")
105+
}
106+
107+
func Test_detectCycle_SimpleCycle(t *testing.T) { // A -> B -> A
108+
graph := map[string][]string{
109+
"A": {"B"},
110+
"B": {"A"},
111+
}
112+
113+
hasCycle := detectCycle(graph, "A")
114+
assert.True(t, hasCycle, "Should detect simple cycle A -> B -> A")
115+
}
116+
117+
func Test_detectCycle_ComplexCycle(t *testing.T) { // A -> B -> C -> A
118+
graph := map[string][]string{
119+
"A": {"B"},
120+
"B": {"C"},
121+
"C": {"A"},
122+
}
123+
124+
hasCycle := detectCycle(graph, "A")
125+
assert.True(t, hasCycle, "Should detect complex cycle A -> B -> C -> A")
126+
}
102127
func TestConvertISOToTaskwarriorFormat(t *testing.T) {
103128
tests := []struct {
104129
name string
@@ -136,6 +161,24 @@ func TestConvertISOToTaskwarriorFormat(t *testing.T) {
136161
expected: "",
137162
hasError: true,
138163
},
164+
{
165+
name: "Compact ISO datetime format (Taskwarrior export)",
166+
input: "20260128T000000Z",
167+
expected: "2026-01-28T00:00:00",
168+
hasError: false,
169+
},
170+
{
171+
name: "Compact ISO datetime format with time",
172+
input: "20260128T143000Z",
173+
expected: "2026-01-28T14:30:00",
174+
hasError: false,
175+
},
176+
{
177+
name: "Compact date only format",
178+
input: "20260128",
179+
expected: "2026-01-28",
180+
hasError: false,
181+
},
139182
}
140183

141184
for _, tt := range tests {

backend/utils/validation.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,54 @@ func ValidateDependencies(depends []string, currentTaskUUID string) error {
1919

2020
return nil
2121
}
22+
23+
type TaskDependency struct {
24+
UUID string `json:"uuid"`
25+
Depends []string `json:"depends"`
26+
Status string `json:"status"`
27+
}
28+
29+
func ValidateCircularDependencies(depends []string, currentTaskUUID string, existingTasks []TaskDependency) error {
30+
if len(depends) == 0 {
31+
return nil
32+
}
33+
34+
dependencyGraph := make(map[string][]string)
35+
for _, task := range existingTasks {
36+
if task.Status == "pending" {
37+
dependencyGraph[task.UUID] = task.Depends
38+
}
39+
}
40+
41+
dependencyGraph[currentTaskUUID] = depends
42+
43+
if hasCycle := detectCycle(dependencyGraph, currentTaskUUID); hasCycle {
44+
return fmt.Errorf("circular dependency detected: adding these dependencies would create a cycle")
45+
}
46+
47+
return nil
48+
}
49+
50+
// (0): unvisited, (1): visiting,(2): visited
51+
func detectCycle(graph map[string][]string, startNode string) bool {
52+
color := make(map[string]int)
53+
return dfsHasCycle(graph, startNode, color)
54+
}
55+
56+
func dfsHasCycle(graph map[string][]string, node string, color map[string]int) bool {
57+
if color[node] == 1 {
58+
return true
59+
}
60+
if color[node] == 2 {
61+
return false
62+
}
63+
64+
color[node] = 1
65+
for _, dep := range graph[node] {
66+
if dfsHasCycle(graph, dep, color) {
67+
return true
68+
}
69+
}
70+
color[node] = 2
71+
return false
72+
}

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ export const Tasks = (
376376
setIsAddTaskOpen(false);
377377
} catch (error) {
378378
console.error('Failed to edit task:', error);
379+
throw error;
379380
}
380381
}
381382

@@ -655,28 +656,46 @@ export const Tasks = (
655656
);
656657
};
657658

658-
const handleDependsSaveClick = (task: Task, depends: string[]) => {
659-
task.depends = depends;
659+
const handleDependsSaveClick = async (task: Task, depends: string[]) => {
660+
try {
661+
setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid]));
660662

661-
setUnsyncedTaskUuids((prev) => new Set([...prev, task.uuid]));
663+
await handleEditTaskOnBackend(
664+
props.email,
665+
props.encryptionSecret,
666+
props.UUID,
667+
task.description,
668+
task.tags,
669+
task.uuid.toString(),
670+
task.project,
671+
task.start,
672+
task.entry || '',
673+
task.wait || '',
674+
task.end || '',
675+
depends,
676+
task.due || '',
677+
task.recur || '',
678+
task.annotations || []
679+
);
680+
} catch (error) {
681+
console.error('Failed to save dependencies:', error);
662682

663-
handleEditTaskOnBackend(
664-
props.email,
665-
props.encryptionSecret,
666-
props.UUID,
667-
task.description,
668-
task.tags,
669-
task.uuid.toString(),
670-
task.project,
671-
task.start,
672-
task.entry || '',
673-
task.wait || '',
674-
task.end || '',
675-
task.depends,
676-
task.due || '',
677-
task.recur || '',
678-
task.annotations || []
679-
);
683+
setUnsyncedTaskUuids((prev) => {
684+
const newSet = new Set(prev);
685+
newSet.delete(task.uuid);
686+
return newSet;
687+
});
688+
689+
toast.error('Failed to save dependencies. Please try again.', {
690+
position: 'bottom-left',
691+
autoClose: 3000,
692+
hideProgressBar: false,
693+
closeOnClick: true,
694+
pauseOnHover: true,
695+
draggable: true,
696+
progress: undefined,
697+
});
698+
}
680699
};
681700

682701
const handleRecurSaveClick = (task: Task, recur: string) => {

frontend/src/components/HomeComponents/Tasks/UseEditTask.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const useEditTask = (selectedTask: Task | null) => {
4545
editedRecur: selectedTask.recur || '',
4646
originalRecur: selectedTask.recur || '',
4747
editedAnnotations: selectedTask.annotations || [],
48+
editedDepends: selectedTask.depends || [],
4849
}));
4950
}
5051
}, [selectedTask]);

0 commit comments

Comments
 (0)