diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go index 7432f53b4..012bf16f1 100644 --- a/veans/internal/bootstrap/bootstrap.go +++ b/veans/internal/bootstrap/bootstrap.go @@ -361,15 +361,28 @@ func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID i if err != nil { return config.Buckets{}, err } - byTitle := map[string]*client.Bucket{} - for _, b := range existing { - byTitle[b.Title] = b + + // Resolve canonical statuses to existing buckets via the alias table. + // Vikunja's default Kanban view ships with "To-Do" / "Doing" / "Done"; + // matching them as Todo / InProgress / Done avoids creating a parallel + // set of buckets every time veans runs against a vanilla project. + matched := map[status.Status]*client.Bucket{} + for _, s := range status.All() { + for _, b := range existing { + if b == nil { + continue + } + if status.MatchBucketTitle(s, b.Title) { + matched[s] = b + break + } + } } - missing := []string{} - for _, t := range status.CanonicalBucketTitles { - if _, ok := byTitle[t]; !ok { - missing = append(missing, t) + var missing []string + for _, s := range status.All() { + if _, ok := matched[s]; !ok { + missing = append(missing, s.BucketTitle()) } } @@ -392,23 +405,33 @@ func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID i } } if approve { - for _, title := range missing { + for _, s := range status.All() { + if _, ok := matched[s]; ok { + continue + } + title := s.BucketTitle() b, err := c.CreateBucket(ctx, projectID, viewID, &client.Bucket{Title: title}) if err != nil { return config.Buckets{}, output.Wrap(output.CodeUnknown, err, "create bucket %q: %v", title, err) } - byTitle[title] = b + matched[s] = b progress(opts.Out, "Created bucket %q (id=%d)", title, b.ID) } } } + for _, s := range status.All() { + if b, ok := matched[s]; ok && b != nil && b.Title != s.BucketTitle() { + progress(opts.Out, "Reusing existing bucket %q as %s (id=%d)", b.Title, s.BucketTitle(), b.ID) + } + } + out := config.Buckets{ - Todo: lookupBucket(byTitle, "Todo"), - InProgress: lookupBucket(byTitle, "In Progress"), - InReview: lookupBucket(byTitle, "In Review"), - Done: lookupBucket(byTitle, "Done"), - Scrapped: lookupBucket(byTitle, "Scrapped"), + Todo: bucketID(matched, status.Todo), + InProgress: bucketID(matched, status.InProgress), + InReview: bucketID(matched, status.InReview), + Done: bucketID(matched, status.Completed), + Scrapped: bucketID(matched, status.Scrapped), } if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 { return config.Buckets{}, output.New(output.CodeValidation, @@ -417,8 +440,8 @@ func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID i return out, nil } -func lookupBucket(by map[string]*client.Bucket, title string) int64 { - if b, ok := by[title]; ok { +func bucketID(m map[status.Status]*client.Bucket, s status.Status) int64 { + if b, ok := m[s]; ok && b != nil { return b.ID } return 0 diff --git a/veans/internal/status/status.go b/veans/internal/status/status.go index 234660c16..68c31e613 100644 --- a/veans/internal/status/status.go +++ b/veans/internal/status/status.go @@ -43,6 +43,7 @@ func All() []Status { } // CanonicalBucketTitles is the strict-with-override list seeded by `init`. +// Order matches the columns we want left-to-right in a Kanban view. var CanonicalBucketTitles = []string{ "Todo", "In Progress", @@ -51,6 +52,36 @@ var CanonicalBucketTitles = []string{ "Scrapped", } +// BucketTitleAliases lists titles that count as the canonical bucket for +// each status. Vikunja's default Kanban view ships with "To-Do", "Doing" +// and "Done" buckets — we accept those so a vanilla project doesn't grow +// parallel buckets when veans init runs against it. The first entry is +// always the canonical name used by CanonicalBucketTitles. +var BucketTitleAliases = map[Status][]string{ + Todo: {"Todo", "To-Do", "ToDo", "To Do", "To do", "Backlog"}, + InProgress: {"In Progress", "In-Progress", "Doing", "WIP", "In progress"}, + InReview: {"In Review", "In-Review", "Review", "In review"}, + Completed: {"Done", "Completed", "Complete"}, + Scrapped: {"Scrapped", "Cancelled", "Canceled", "Won't Do", "Wontfix"}, +} + +// MatchBucketTitle reports whether `title` matches `s` either as the +// canonical title or one of its aliases. Comparison is case-insensitive +// and tolerant of stray whitespace. +func MatchBucketTitle(s Status, title string) bool { + want := normalizeBucketTitle(title) + for _, alias := range BucketTitleAliases[s] { + if normalizeBucketTitle(alias) == want { + return true + } + } + return false +} + +func normalizeBucketTitle(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + // BucketTitle returns the bucket name that backs each status. func (s Status) BucketTitle() string { switch s { diff --git a/veans/internal/status/status_test.go b/veans/internal/status/status_test.go index f38c21f02..7ee66b747 100644 --- a/veans/internal/status/status_test.go +++ b/veans/internal/status/status_test.go @@ -63,6 +63,53 @@ func TestDoneFlag(t *testing.T) { } } +func TestMatchBucketTitle(t *testing.T) { + cases := []struct { + title string + want Status + }{ + // Vikunja defaults + {"To-Do", Todo}, + {"Doing", InProgress}, + {"Done", Completed}, + // Canonical titles + {"Todo", Todo}, + {"In Progress", InProgress}, + {"In Review", InReview}, + {"Scrapped", Scrapped}, + // Case-insensitive + whitespace tolerant + {" todo ", Todo}, + {"DOING", InProgress}, + // A few common aliases + {"WIP", InProgress}, + {"Backlog", Todo}, + {"Cancelled", Scrapped}, + {"Won't Do", Scrapped}, + } + for _, c := range cases { + matched := false + for _, s := range All() { + if MatchBucketTitle(s, c.title) { + if s != c.want { + t.Errorf("MatchBucketTitle(%q): matched %q, want %q", c.title, s, c.want) + } + matched = true + break + } + } + if !matched { + t.Errorf("MatchBucketTitle(%q): no status matched, want %q", c.title, c.want) + } + } + + // Negative: a non-canonical name shouldn't match anything. + for _, s := range All() { + if MatchBucketTitle(s, "random-bucket-name") { + t.Errorf("MatchBucketTitle(%q, \"random\") unexpectedly true", s) + } + } +} + func TestBucketIDRoundTrip(t *testing.T) { b := config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15} for _, s := range All() {