diff --git a/veans/internal/commands/claim.go b/veans/internal/commands/claim.go new file mode 100644 index 000000000..312d366b7 --- /dev/null +++ b/veans/internal/commands/claim.go @@ -0,0 +1,79 @@ +package commands + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/status" +) + +func newClaimCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "claim ", + Short: "Claim a task: assign the bot, move to In Progress, tag with branch", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := loadRuntime() + if err != nil { + return err + } + id, err := rt.resolveTaskID(cmd.Context(), args[0]) + if err != nil { + return err + } + + // Move to In Progress. + bid, err := status.BucketID(status.InProgress, rt.cfg.Buckets) + if err != nil { + return err + } + task, err := rt.client.UpdateTask(cmd.Context(), id, &client.Task{ + ID: id, + BucketID: bid, + Done: false, + }) + if err != nil { + return err + } + + // Assign the bot. Idempotent on repeat — Vikunja returns 409 if + // already assigned, which we map to a soft-skip. + if err := rt.client.AddAssignee(cmd.Context(), id, rt.cfg.Bot.UserID); err != nil { + if oe, ok := err.(*output.Error); !ok || oe.Code != output.CodeConflict { + return err + } + } + + // Tag with the current branch label, if there is one. + if branch := currentGitBranch(); branch != "" { + labelTitle := branchLabel(branch) + l, err := getOrCreateLabelByTitle(cmd.Context(), rt.client, labelTitle) + if err != nil { + return err + } + if err := rt.client.AddLabelToTask(cmd.Context(), id, l.ID); err != nil { + if oe, ok := err.(*output.Error); !ok || oe.Code != output.CodeConflict { + return err + } + } + } + + fresh, err := rt.client.GetTask(cmd.Context(), id) + if err == nil { + task = fresh + } + + if globals.JSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(task) + } + fmt.Fprintf(cmd.OutOrStdout(), "Claimed %s %s\n", + rt.cfg.FormatTaskID(task.Index), task.Title) + return nil + }, + } + return cmd +} diff --git a/veans/internal/commands/create.go b/veans/internal/commands/create.go new file mode 100644 index 000000000..21e218b66 --- /dev/null +++ b/veans/internal/commands/create.go @@ -0,0 +1,134 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/status" +) + +type createFlags struct { + description string + statusName string + priority int64 + labels []string + parent string + blockedBy []string +} + +func newCreateCmd() *cobra.Command { + f := &createFlags{} + cmd := &cobra.Command{ + Use: "create ", + Aliases: []string{"c"}, + Short: "Create a new task", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := loadRuntime() + if err != nil { + return err + } + task, err := runCreate(cmd.Context(), rt, args[0], f) + if err != nil { + return err + } + if globals.JSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(task) + } + fmt.Fprintf(cmd.OutOrStdout(), "Created %s %s\n", + rt.cfg.FormatTaskID(task.Index), task.Title) + return nil + }, + } + cmd.Flags().StringVarP(&f.description, "description", "d", "", "task description (markdown)") + cmd.Flags().StringVarP(&f.statusName, "status", "s", "todo", "initial status (defaults to todo)") + cmd.Flags().Int64Var(&f.priority, "priority", 0, "priority (0=unset, 1=low, 5=DO_NOW)") + cmd.Flags().StringSliceVar(&f.labels, "label", nil, "labels to attach (repeatable; veans: prefix added if missing)") + cmd.Flags().StringVar(&f.parent, "parent", "", "parent task ID (creates parenttask relation)") + cmd.Flags().StringSliceVar(&f.blockedBy, "blocked-by", nil, "task IDs that block this one (repeatable)") + return cmd +} + +func runCreate(ctx context.Context, rt *runtime, title string, f *createFlags) (*client.Task, error) { + st, err := status.Parse(f.statusName) + if err != nil { + return nil, err + } + bucketID, err := status.BucketID(st, rt.cfg.Buckets) + if err != nil { + return nil, err + } + + created, err := rt.client.CreateTask(ctx, rt.cfg.ProjectID, &client.Task{ + Title: strings.TrimSpace(title), + Description: f.description, + Priority: f.priority, + ProjectID: rt.cfg.ProjectID, + BucketID: bucketID, + Done: st.Done(), + }) + if err != nil { + return nil, err + } + + // If the initial bucket isn't where Vikunja put it (defaults to first + // bucket on the view), nudge it explicitly. + if created.BucketID != bucketID { + updated, err := rt.client.UpdateTask(ctx, created.ID, &client.Task{ + ID: created.ID, + BucketID: bucketID, + Done: st.Done(), + }) + if err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "set initial bucket: %v", err) + } + created = updated + } + + // Attach labels (lazily creating them under veans: namespace). + for _, raw := range f.labels { + title := normalizeLabelTitle(raw) + l, err := getOrCreateLabelByTitle(ctx, rt.client, title) + if err != nil { + return nil, output.Wrap(output.CodeUnknown, err, "label %q: %v", title, err) + } + if err := rt.client.AddLabelToTask(ctx, created.ID, l.ID); err != nil { + return nil, err + } + } + + // Parent relation. + if f.parent != "" { + parentID, err := rt.resolveTaskID(ctx, f.parent) + if err != nil { + return nil, err + } + if _, err := rt.client.CreateRelation(ctx, created.ID, parentID, "parenttask"); err != nil { + return nil, err + } + } + + // Blocked-by relations. + for _, ref := range f.blockedBy { + blockerID, err := rt.resolveTaskID(ctx, ref) + if err != nil { + return nil, err + } + if _, err := rt.client.CreateRelation(ctx, created.ID, blockerID, "blocked"); err != nil { + return nil, err + } + } + + // Re-fetch so the response reflects the labels and any post-create state. + final, err := rt.client.GetTask(ctx, created.ID) + if err != nil { + return created, nil // partial success — caller still got a usable task + } + return final, nil +} diff --git a/veans/internal/commands/labels.go b/veans/internal/commands/labels.go new file mode 100644 index 000000000..0dc1830ec --- /dev/null +++ b/veans/internal/commands/labels.go @@ -0,0 +1,57 @@ +package commands + +import ( + "context" + "strings" + + "code.vikunja.io/veans/internal/client" +) + +// labelNamespace is auto-prepended to label names that don't already have it, +// so the agent's labels live in their own corner of the user's global label +// list and don't pollute manually-curated labels. +const labelNamespace = "veans:" + +func normalizeLabelTitle(raw string) string { + t := strings.TrimSpace(raw) + if t == "" { + return "" + } + if strings.HasPrefix(t, labelNamespace) { + return t + } + return labelNamespace + t +} + +// getOrCreateLabelByTitle returns the ID of the label with the given title, +// creating it under the current user if it doesn't exist. Labels are global +// per user in Vikunja, so this only finds labels visible to whoever the +// `c` client is authenticated as (i.e. the bot when called from veans). +func getOrCreateLabelByTitle(ctx context.Context, c *client.Client, title string) (*client.Label, error) { + existing, err := c.ListLabels(ctx, title) + if err != nil { + return nil, err + } + for _, l := range existing { + if l.Title == title { + return l, nil + } + } + created, err := c.CreateLabel(ctx, &client.Label{Title: title}) + if err != nil { + return nil, err + } + return created, nil +} + +// findLabelOnTask returns the label with the given (already-normalized) +// title attached to the task, or nil. Used by --label-remove to know which +// label ID to detach. +func findLabelOnTask(t *client.Task, title string) *client.Label { + for _, l := range t.Labels { + if l != nil && l.Title == title { + return l + } + } + return nil +} diff --git a/veans/internal/commands/root.go b/veans/internal/commands/root.go index 0c35df120..29e603d7f 100644 --- a/veans/internal/commands/root.go +++ b/veans/internal/commands/root.go @@ -37,6 +37,9 @@ func Root(version string) *cobra.Command { root.AddCommand(newInitCmd()) root.AddCommand(newListCmd()) root.AddCommand(newShowCmd()) + root.AddCommand(newCreateCmd()) + root.AddCommand(newUpdateCmd()) + root.AddCommand(newClaimCmd()) return root } diff --git a/veans/internal/commands/update.go b/veans/internal/commands/update.go new file mode 100644 index 000000000..7a6f57475 --- /dev/null +++ b/veans/internal/commands/update.go @@ -0,0 +1,239 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "code.vikunja.io/veans/internal/client" + "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/status" +) + +type updateFlags struct { + statusName string + title string + priority int64 + priorityIsSet bool + addLabels []string + removeLabels []string + description string + descriptionIsSet bool + replaceOld string + replaceNew string + descriptionApp string + comment string + reason string + ifUnchangedSince string +} + +func newUpdateCmd() *cobra.Command { + f := &updateFlags{} + cmd := &cobra.Command{ + Use: "update <id>", + Aliases: []string{"u"}, + Short: "Update a task by PROJ-NN, #NN, or numeric ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := loadRuntime() + if err != nil { + return err + } + f.descriptionIsSet = cmd.Flags().Changed("description") + f.priorityIsSet = cmd.Flags().Changed("priority") + + id, err := rt.resolveTaskID(cmd.Context(), args[0]) + if err != nil { + return err + } + task, err := runUpdate(cmd.Context(), rt, id, f) + if err != nil { + return err + } + if globals.JSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(task) + } + s := status.FromBucketID(task.BucketID, rt.cfg.Buckets) + fmt.Fprintf(cmd.OutOrStdout(), "Updated %s [%s] %s\n", + rt.cfg.FormatTaskID(task.Index), s, task.Title) + return nil + }, + } + cmd.Flags().StringVarP(&f.statusName, "status", "s", "", "transition to a status") + cmd.Flags().StringVarP(&f.title, "title", "t", "", "new title") + cmd.Flags().Int64Var(&f.priority, "priority", 0, "new priority") + cmd.Flags().StringSliceVar(&f.addLabels, "label-add", nil, "labels to attach (repeatable; veans: prefix added if missing)") + cmd.Flags().StringSliceVar(&f.removeLabels, "label-remove", nil, "labels to detach (repeatable)") + cmd.Flags().StringVar(&f.description, "description", "", "replace the entire description") + cmd.Flags().StringVar(&f.replaceOld, "description-replace-old", "", "exact-match string to replace in description (must be unique)") + cmd.Flags().StringVar(&f.replaceNew, "description-replace-new", "", "replacement for --description-replace-old") + cmd.Flags().StringVar(&f.descriptionApp, "description-append", "", "append text to the existing description") + cmd.Flags().StringVarP(&f.comment, "comment", "c", "", "post a comment as part of this update") + cmd.Flags().StringVar(&f.reason, "reason", "", "rationale (required when --status scrapped)") + cmd.Flags().StringVar(&f.ifUnchangedSince, "if-unchanged-since", "", "RFC3339 timestamp; abort if the task has changed since") + return cmd +} + +func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*client.Task, error) { + current, err := rt.client.GetTask(ctx, id) + if err != nil { + return nil, err + } + + // Optimistic concurrency. + if f.ifUnchangedSince != "" { + ts, err := time.Parse(time.RFC3339, f.ifUnchangedSince) + if err != nil { + return nil, output.Wrap(output.CodeValidation, err, "parse --if-unchanged-since: %v", err) + } + if current.Updated.After(ts) { + return nil, output.New(output.CodeConflict, + "task %s changed at %s, after --if-unchanged-since %s", + rt.cfg.FormatTaskID(current.Index), current.Updated.Format(time.RFC3339), ts.Format(time.RFC3339)) + } + } + + // Resolve new status / done flag if --status is set. + var newStatus status.Status + if f.statusName != "" { + s, err := status.Parse(f.statusName) + if err != nil { + return nil, err + } + newStatus = s + if s == status.Scrapped && strings.TrimSpace(f.reason) == "" { + return nil, output.New(output.CodeValidation, "--reason is required when --status scrapped") + } + } + + // Build the update payload incrementally so we don't clobber unmentioned + // fields. The base must include the ID; bucket/done are conditional. + body := &client.Task{ID: id} + dirty := false + + if f.title != "" { + body.Title = f.title + dirty = true + } + if f.priorityIsSet { + body.Priority = f.priority + dirty = true + } + + // Description ops are mutually-exclusive layers; --description wins + // outright, otherwise replace-old/new + append run on the current body. + newDesc, descChanged, err := composeDescription(current.Description, f) + if err != nil { + return nil, err + } + if descChanged { + body.Description = newDesc + dirty = true + } + + if newStatus != "" { + bid, err := status.BucketID(newStatus, rt.cfg.Buckets) + if err != nil { + return nil, err + } + body.BucketID = bid + body.Done = newStatus.Done() + dirty = true + } + + // Comment first when transitioning to scrapped — the reason is part of + // the audit trail and should appear before the bucket move in the log. + if newStatus == status.Scrapped { + if _, err := rt.client.AddTaskComment(ctx, id, "**Scrapped:** "+strings.TrimSpace(f.reason)); err != nil { + return nil, err + } + } + if f.comment != "" { + if _, err := rt.client.AddTaskComment(ctx, id, f.comment); err != nil { + return nil, err + } + } + + // Apply the field update if anything changed. + updated := current + if dirty { + u, err := rt.client.UpdateTask(ctx, id, body) + if err != nil { + return nil, err + } + updated = u + } + + // Label add/remove run after the field update so a status transition + // can't clobber freshly-attached labels. + for _, raw := range f.addLabels { + title := normalizeLabelTitle(raw) + l, err := getOrCreateLabelByTitle(ctx, rt.client, title) + if err != nil { + return nil, err + } + if err := rt.client.AddLabelToTask(ctx, id, l.ID); err != nil { + return nil, err + } + } + for _, raw := range f.removeLabels { + title := normalizeLabelTitle(raw) + if l := findLabelOnTask(updated, title); l != nil { + if err := rt.client.RemoveLabelFromTask(ctx, id, l.ID); err != nil { + return nil, err + } + } + } + + if len(f.addLabels) > 0 || len(f.removeLabels) > 0 { + fresh, err := rt.client.GetTask(ctx, id) + if err == nil { + updated = fresh + } + } + + return updated, nil +} + +// composeDescription folds --description / --description-replace-* / --description-append +// into the existing body. Returns (new, changed, error). +func composeDescription(existing string, f *updateFlags) (string, bool, error) { + if f.descriptionIsSet { + // --description replaces wholesale. + return f.description, true, nil + } + + out := existing + changed := false + + if f.replaceOld != "" || f.replaceNew != "" { + if f.replaceOld == "" { + return "", false, output.New(output.CodeValidation, "--description-replace-new requires --description-replace-old") + } + count := strings.Count(out, f.replaceOld) + switch { + case count == 0: + return "", false, output.New(output.CodeValidation, + "--description-replace-old not found in description") + case count > 1: + return "", false, output.New(output.CodeValidation, + "--description-replace-old matched %d times — make it unique", count) + } + out = strings.Replace(out, f.replaceOld, f.replaceNew, 1) + changed = true + } + + if f.descriptionApp != "" { + if out != "" && !strings.HasSuffix(out, "\n") { + out += "\n" + } + out += f.descriptionApp + changed = true + } + + return out, changed, nil +} diff --git a/veans/internal/commands/update_test.go b/veans/internal/commands/update_test.go new file mode 100644 index 000000000..46985946f --- /dev/null +++ b/veans/internal/commands/update_test.go @@ -0,0 +1,105 @@ +package commands + +import ( + "strings" + "testing" +) + +func TestComposeDescription_FullReplace(t *testing.T) { + f := &updateFlags{description: "new body", descriptionIsSet: true} + got, changed, err := composeDescription("old body", f) + if err != nil { + t.Fatal(err) + } + if !changed || got != "new body" { + t.Fatalf("got %q changed=%v", got, changed) + } +} + +func TestComposeDescription_SurgicalReplace(t *testing.T) { + f := &updateFlags{ + replaceOld: "TODO", + replaceNew: "DONE", + } + got, changed, err := composeDescription("- [ ] TODO part 1\n- [ ] something else", f) + if err != nil { + t.Fatal(err) + } + if !changed || !strings.Contains(got, "DONE part 1") { + t.Fatalf("got %q", got) + } +} + +func TestComposeDescription_ReplaceNotUnique(t *testing.T) { + f := &updateFlags{ + replaceOld: "x", + replaceNew: "y", + } + if _, _, err := composeDescription("xxx", f); err == nil { + t.Fatal("expected error on non-unique match") + } +} + +func TestComposeDescription_ReplaceNotFound(t *testing.T) { + f := &updateFlags{ + replaceOld: "missing", + replaceNew: "y", + } + if _, _, err := composeDescription("hello", f); err == nil { + t.Fatal("expected error on no match") + } +} + +func TestComposeDescription_Append(t *testing.T) { + f := &updateFlags{descriptionApp: "## Notes"} + got, changed, err := composeDescription("body", f) + if err != nil { + t.Fatal(err) + } + if !changed || got != "body\n## Notes" { + t.Fatalf("got %q", got) + } +} + +func TestComposeDescription_AppendOnEmpty(t *testing.T) { + f := &updateFlags{descriptionApp: "first line"} + got, changed, err := composeDescription("", f) + if err != nil { + t.Fatal(err) + } + if !changed || got != "first line" { + t.Fatalf("got %q", got) + } +} + +func TestComposeDescription_NoOp(t *testing.T) { + f := &updateFlags{} + got, changed, err := composeDescription("body", f) + if err != nil { + t.Fatal(err) + } + if changed || got != "body" { + t.Fatalf("expected no-op, got %q changed=%v", got, changed) + } +} + +func TestComposeDescription_ReplaceNewWithoutOld(t *testing.T) { + f := &updateFlags{replaceNew: "y"} + if _, _, err := composeDescription("body", f); err == nil { + t.Fatal("expected error: --description-replace-new without --description-replace-old") + } +} + +func TestNormalizeLabelTitle(t *testing.T) { + cases := map[string]string{ + "foo": "veans:foo", + "veans:bar": "veans:bar", + " baz ": "veans:baz", + "veans:already-prefixed": "veans:already-prefixed", + } + for in, want := range cases { + if got := normalizeLabelTitle(in); got != want { + t.Errorf("normalize(%q) = %q, want %q", in, got, want) + } + } +}