mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-06-01 19:01:37 +00:00
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:
79
veans/internal/commands/claim.go
Normal file
79
veans/internal/commands/claim.go
Normal 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
|
||||
}
|
||||
134
veans/internal/commands/create.go
Normal file
134
veans/internal/commands/create.go
Normal 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
|
||||
}
|
||||
57
veans/internal/commands/labels.go
Normal file
57
veans/internal/commands/labels.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
239
veans/internal/commands/update.go
Normal file
239
veans/internal/commands/update.go
Normal 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
|
||||
}
|
||||
105
veans/internal/commands/update_test.go
Normal file
105
veans/internal/commands/update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user