feat(veans): create, update, claim with concurrency + label ops

Phase 5: write path.

- create: --description, --status (default todo), --priority, --label
  (auto-namespaced under veans:), --parent, --blocked-by. Re-fetches
  after side effects so the response reflects labels and relations.
- update: --status, --title, --priority, --label-add/remove,
  --description (full replace), --description-replace-old/new (errors
  on zero or multi-match — mirrors agent Edit semantics),
  --description-append, --comment, --reason (required for --status
  scrapped, posted as 'Scrapped:' comment before the bucket move),
  --if-unchanged-since (RFC3339; CodeConflict on stale).
- claim: assign the bot, move to In Progress, attach
  veans:branch:<current-branch> label. Treats already-assigned /
  already-labeled as soft-success (CodeConflict tolerated).

Labels are global per user in Vikunja, so the bot's label list is
implicitly namespaced — getOrCreateLabelByTitle handles lazy creation
and returns the existing row on hit.
This commit is contained in:
Claude
2026-05-07 21:11:38 +00:00
parent 2229ff968d
commit 653f44a18f
6 changed files with 617 additions and 0 deletions

View File

@@ -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 <id>",
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
}

View File

@@ -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 <title>",
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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}
}