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 {
|
if err != nil {
|
||||||
return config.Buckets{}, err
|
return config.Buckets{}, err
|
||||||
}
|
}
|
||||||
byTitle := map[string]*client.Bucket{}
|
|
||||||
for _, b := range existing {
|
// Resolve canonical statuses to existing buckets via the alias table.
|
||||||
byTitle[b.Title] = b
|
// 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{}
|
var missing []string
|
||||||
for _, t := range status.CanonicalBucketTitles {
|
for _, s := range status.All() {
|
||||||
if _, ok := byTitle[t]; !ok {
|
if _, ok := matched[s]; !ok {
|
||||||
missing = append(missing, t)
|
missing = append(missing, s.BucketTitle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,23 +405,33 @@ func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if approve {
|
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})
|
b, err := c.CreateBucket(ctx, projectID, viewID, &client.Bucket{Title: title})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config.Buckets{}, output.Wrap(output.CodeUnknown, err, "create bucket %q: %v", title, err)
|
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)
|
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{
|
out := config.Buckets{
|
||||||
Todo: lookupBucket(byTitle, "Todo"),
|
Todo: bucketID(matched, status.Todo),
|
||||||
InProgress: lookupBucket(byTitle, "In Progress"),
|
InProgress: bucketID(matched, status.InProgress),
|
||||||
InReview: lookupBucket(byTitle, "In Review"),
|
InReview: bucketID(matched, status.InReview),
|
||||||
Done: lookupBucket(byTitle, "Done"),
|
Done: bucketID(matched, status.Completed),
|
||||||
Scrapped: lookupBucket(byTitle, "Scrapped"),
|
Scrapped: bucketID(matched, status.Scrapped),
|
||||||
}
|
}
|
||||||
if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 {
|
if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 {
|
||||||
return config.Buckets{}, output.New(output.CodeValidation,
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func lookupBucket(by map[string]*client.Bucket, title string) int64 {
|
func bucketID(m map[status.Status]*client.Bucket, s status.Status) int64 {
|
||||||
if b, ok := by[title]; ok {
|
if b, ok := m[s]; ok && b != nil {
|
||||||
return b.ID
|
return b.ID
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ func All() []Status {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanonicalBucketTitles is the strict-with-override list seeded by `init`.
|
// 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{
|
var CanonicalBucketTitles = []string{
|
||||||
"Todo",
|
"Todo",
|
||||||
"In Progress",
|
"In Progress",
|
||||||
|
|
@ -51,6 +52,36 @@ var CanonicalBucketTitles = []string{
|
||||||
"Scrapped",
|
"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.
|
// BucketTitle returns the bucket name that backs each status.
|
||||||
func (s Status) BucketTitle() string {
|
func (s Status) BucketTitle() string {
|
||||||
switch s {
|
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) {
|
func TestBucketIDRoundTrip(t *testing.T) {
|
||||||
b := config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15}
|
b := config.Buckets{Todo: 11, InProgress: 12, InReview: 13, Done: 14, Scrapped: 15}
|
||||||
for _, s := range All() {
|
for _, s := range All() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue