feat(veans): match existing bucket titles via case-insensitive alias table
This commit is contained in:
parent
4ac89741e3
commit
1bc3afa430
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue