u.go

  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}