1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package t
6
7import (
8 "errors"
9 "fmt"
10 "strings"
11
12 "git.secluded.site/np/cmd/shared"
13 "git.secluded.site/np/internal/db"
14 "git.secluded.site/np/internal/event"
15 "git.secluded.site/np/internal/task"
16 "github.com/spf13/cobra"
17)
18
19var uCmd = &cobra.Command{
20 Use: "u",
21 Short: "Update task",
22 Long: `Update a task's status, title, or description`,
23 RunE: runUpdateTask,
24}
25
26func init() {
27 TCmd.AddCommand(uCmd)
28
29 uCmd.Flags().StringP("id", "i", "", "Task ID (required)")
30 uCmd.Flags().StringP("status", "s", "", "New task status")
31 uCmd.Flags().StringP("title", "t", "", "New task title")
32 uCmd.Flags().StringP("description", "d", "", "New task description")
33 uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)")
34 _ = uCmd.MarkFlagRequired("id")
35}
36
37func runUpdateTask(cmd *cobra.Command, _ []string) error {
38 env, err := shared.Environment(cmd)
39 if err != nil {
40 return err
41 }
42
43 sessionDoc, found, err := shared.ActiveSession(cmd, env)
44 if err != nil {
45 return err
46 }
47 if !found {
48 return nil
49 }
50
51 taskID, err := cmd.Flags().GetString("id")
52 if err != nil {
53 return err
54 }
55 taskID = strings.TrimSpace(taskID)
56
57 current, err := env.TaskStore.Get(cmd.Context(), sessionDoc.SID, taskID)
58 if err != nil {
59 if errors.Is(err, task.ErrNotFound) {
60 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
61 return nil
62 }
63 return err
64 }
65
66 titleInput, err := cmd.Flags().GetString("title")
67 if err != nil {
68 return err
69 }
70 descInput, err := cmd.Flags().GetString("description")
71 if err != nil {
72 return err
73 }
74 statusInput, err := cmd.Flags().GetString("status")
75 if err != nil {
76 return err
77 }
78 reasonInput, err := cmd.Flags().GetString("reason")
79 if err != nil {
80 return err
81 }
82
83 newTitle := current.Title
84 titleChanged := cmd.Flags().Changed("title")
85 if titleChanged {
86 newTitle = strings.TrimSpace(titleInput)
87 if newTitle == "" {
88 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.")
89 return nil
90 }
91 }
92
93 newDescription := current.Description
94 descriptionChanged := cmd.Flags().Changed("description")
95 if descriptionChanged {
96 newDescription = strings.TrimSpace(descInput)
97 }
98
99 var newStatus task.Status
100 statusChanged := cmd.Flags().Changed("status")
101 if statusChanged {
102 newStatus, err = task.ParseStatus(statusInput)
103 if err != nil {
104 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusInput)
105 return nil
106 }
107 } else {
108 newStatus = current.Status
109 }
110
111 if !titleChanged && !descriptionChanged && !statusChanged {
112 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.")
113 return nil
114 }
115
116 reason := strings.TrimSpace(reasonInput)
117 reasonRequired := titleChanged || descriptionChanged ||
118 (statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
119
120 if reasonRequired && reason == "" {
121 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed.")
122 return nil
123 }
124
125 contentChanged := titleChanged || descriptionChanged
126 statusOnlyChanged := statusChanged && !contentChanged
127
128 var updated task.Task
129 err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
130 taskTxn := env.TaskStore.WithTxn(txn)
131 eventTxn := env.EventStore.WithTxn(txn)
132 sessionTxn := env.SessionStore.WithTxn(txn)
133
134 before, err := taskTxn.Get(sessionDoc.SID, taskID)
135 if err != nil {
136 return err
137 }
138
139 updated, err = taskTxn.Update(sessionDoc.SID, taskID, func(t *task.Task) error {
140 if titleChanged {
141 t.Title = newTitle
142 }
143 if descriptionChanged {
144 t.Description = newDescription
145 }
146 if statusChanged {
147 t.Status = newStatus
148 }
149 return nil
150 })
151 if err != nil {
152 return err
153 }
154
155 if contentChanged {
156 _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
157 if err != nil {
158 return err
159 }
160 }
161
162 if statusChanged {
163 _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
164 if err != nil {
165 return err
166 }
167 }
168
169 _, err = sessionTxn.TouchAt(sessionDoc.SID, updated.UpdatedAt)
170 return err
171 })
172 if err != nil {
173 return err
174 }
175
176 if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil {
177 return err
178 }
179
180 out := cmd.OutOrStdout()
181 _, _ = fmt.Fprintln(out, "")
182 if statusOnlyChanged && newStatus == task.StatusCompleted {
183 _, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks with `np t u`.")
184 } else {
185 _, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")
186 }
187
188 return nil
189}