feat(veans): match existing bucket titles via case-insensitive alias table

This commit is contained in:
Tink bot 2026-05-26 22:46:49 +02:00 committed by kolaente
parent 4ac89741e3
commit 1bc3afa430
3 changed files with 117 additions and 16 deletions

View File

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

View File

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

View File

@ -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() {