From aecfeaa4047b355a98de767654f41bc46a013507 Mon Sep 17 00:00:00 2001 From: Bohan-J Date: Thu, 16 Jul 2026 13:11:54 +0800 Subject: [PATCH] feat(issue-status): alias resolver for Category/legacy/name inputs (MUL-4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 foundation. Resolve() maps a status string to a workspace's issue_status row with a fixed priority order (plan §3.1): 1. Category alias (backlog|todo|in_progress|done|cancelled) -> that Category's current default status (survives renames of the default). 2. Legacy alias (in_review|blocked) -> the built-in with that system_key. 3. Exact active display name (case-insensitive). No fuzzy matching; anything else returns *InvalidStatusError enumerating the legal Category aliases, legacy aliases, and active names so the API/CLI can echo them back instead of leaving an agent to guess after a rename. Category aliases use underscores and never collide with space-rendered display names. Also exposes IsReservedStatusToken (the 7 tokens no custom status may use) and the Categories list, both consumed by the upcoming status-management API and double-write paths. DB-backed tests cover every branch, including plan example A (renamed Todo default still reached by `todo`, custom Todo reached only by exact name). Co-authored-by: multica-agent --- server/internal/issuestatus/resolver.go | 153 +++++++++++++++ server/internal/issuestatus/resolver_test.go | 190 +++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 server/internal/issuestatus/resolver.go create mode 100644 server/internal/issuestatus/resolver_test.go diff --git a/server/internal/issuestatus/resolver.go b/server/internal/issuestatus/resolver.go new file mode 100644 index 0000000000..1d7ea076b1 --- /dev/null +++ b/server/internal/issuestatus/resolver.go @@ -0,0 +1,153 @@ +package issuestatus + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// Categories are the 5 immutable machine-readable status categories. This is +// the ONLY status semantics any automation may branch on. +var Categories = []string{"backlog", "todo", "in_progress", "done", "cancelled"} + +// categoryAliasSet is the set of Category alias tokens. Each resolves to the +// workspace's current default status for that Category. +var categoryAliasSet = map[string]struct{}{ + "backlog": {}, "todo": {}, "in_progress": {}, "done": {}, "cancelled": {}, +} + +// legacyAliases maps the 2 legacy status tokens to the built-in system_key they +// resolve to. They survive display-name renames because they key on system_key, +// not on the (mutable) name. +var legacyAliases = map[string]string{ + "in_review": "in_review", + "blocked": "blocked", +} + +// ReservedStatusTokens are the 7 tokens that no custom status display name may +// take, because the alias resolver claims them first (5 Category aliases + 2 +// legacy aliases). The status-management API rejects a create/rename to any of +// these. +var ReservedStatusTokens = []string{ + "backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", +} + +// IsReservedStatusToken reports whether name (case-insensitive, trimmed) is one +// of the reserved alias tokens. +func IsReservedStatusToken(name string) bool { + norm := strings.ToLower(strings.TrimSpace(name)) + if _, ok := categoryAliasSet[norm]; ok { + return true + } + _, ok := legacyAliases[norm] + return ok +} + +// InvalidStatusError is returned by Resolve when the input matches no Category +// alias, legacy alias, or active display name. It enumerates the currently +// legal values so the API/CLI can echo them back (plan §3.2) instead of leaving +// an agent to guess after a status has been renamed. It maps to HTTP 400 +// invalid_status at the handler boundary. +type InvalidStatusError struct { + Input string + CategoryAliases []string // the 5 Category alias tokens + LegacyAliases []string // in_review, blocked + Names []string // exact active display names +} + +func (e *InvalidStatusError) Error() string { + return fmt.Sprintf("invalid status %q: expected a Category alias (%s), a legacy alias (%s), or an exact status name (%s)", + e.Input, + strings.Join(e.CategoryAliases, ", "), + strings.Join(e.LegacyAliases, ", "), + strings.Join(e.Names, ", "), + ) +} + +// Resolve maps a status string to the workspace's issue_status row (MUL-4809, +// plan §3.1). Resolution is case-insensitive, trims surrounding whitespace, and +// applies a fixed priority order: +// +// 1. Category alias (backlog | todo | in_progress | done | cancelled) -> +// that Category's current default status. So `todo` keeps working even +// after the default Todo status is renamed. +// 2. Legacy alias (in_review | blocked) -> the built-in status with that +// system_key. Survives renames for the same reason. +// 3. Exact active display name (case-insensitive) -> that status. This is how +// a caller targets a specific workflow stage or a custom status. +// +// No fuzzy matching: anything else yields *InvalidStatusError carrying the +// legal values. Category aliases use underscores (`in_progress`) and never +// collide with display names, which render with spaces (`In Progress`). +func Resolve(ctx context.Context, q *db.Queries, workspaceID pgtype.UUID, input string) (db.IssueStatus, error) { + norm := strings.ToLower(strings.TrimSpace(input)) + + statuses, err := q.ListWorkspaceIssueStatuses(ctx, db.ListWorkspaceIssueStatusesParams{WorkspaceID: workspaceID}) + if err != nil { + return db.IssueStatus{}, fmt.Errorf("load workspace issue statuses: %w", err) + } + + if norm != "" { + // 1. Category alias -> current default for that Category. + if _, ok := categoryAliasSet[norm]; ok { + for _, s := range statuses { + if s.Category == norm && s.IsDefault { + return s, nil + } + } + // The one-default-per-category invariant is seeded and maintained in + // the service layer; a missing default is a data-integrity bug, not a + // user input error. + return db.IssueStatus{}, fmt.Errorf("workspace %s has no default status for category %q", uuidToString(workspaceID), norm) + } + + // 2. Legacy alias -> built-in status by system_key. + if systemKey, ok := legacyAliases[norm]; ok { + for _, s := range statuses { + if s.SystemKey.Valid && s.SystemKey.String == systemKey { + return s, nil + } + } + return db.IssueStatus{}, fmt.Errorf("workspace %s is missing built-in status %q", uuidToString(workspaceID), systemKey) + } + + // 3. Exact active display name (case-insensitive). + for _, s := range statuses { + if strings.ToLower(s.Name) == norm { + return s, nil + } + } + } + + return db.IssueStatus{}, newInvalidStatusError(input, statuses) +} + +// newInvalidStatusError builds the enumerated-options error from the current +// active catalog. +func newInvalidStatusError(input string, statuses []db.IssueStatus) *InvalidStatusError { + names := make([]string, 0, len(statuses)) + for _, s := range statuses { + names = append(names, s.Name) + } + sort.Strings(names) + return &InvalidStatusError{ + Input: input, + CategoryAliases: append([]string(nil), Categories...), + LegacyAliases: []string{"in_review", "blocked"}, + Names: names, + } +} + +// uuidToString renders a pgtype.UUID for error messages. Empty/invalid UUIDs +// render as the zero UUID rather than panicking. +func uuidToString(id pgtype.UUID) string { + if !id.Valid { + return "00000000-0000-0000-0000-000000000000" + } + b := id.Bytes + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/server/internal/issuestatus/resolver_test.go b/server/internal/issuestatus/resolver_test.go new file mode 100644 index 0000000000..1fd148de5d --- /dev/null +++ b/server/internal/issuestatus/resolver_test.go @@ -0,0 +1,190 @@ +package issuestatus + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// seededWorkspace returns a workspace with the 7 built-in statuses already +// seeded, ready for resolution tests. +func seededWorkspace(ctx context.Context, t *testing.T) (pgtype.UUID, *db.Queries) { + t.Helper() + q := db.New(testPool) + wsID := freshWorkspace(ctx, t) + if err := Ensure(ctx, q, wsID); err != nil { + t.Fatalf("seed: %v", err) + } + return wsID, q +} + +func TestResolveCategoryAlias(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + cases := map[string]string{ // input -> expected system_key of the category default + "backlog": "backlog", + "todo": "todo", + "in_progress": "in_progress", + "done": "done", + "cancelled": "cancelled", + " TODO ": "todo", // trimmed + case-insensitive + "In_Progress": "in_progress", // case-insensitive + } + for input, wantKey := range cases { + s, err := Resolve(ctx, q, wsID, input) + if err != nil { + t.Errorf("Resolve(%q): %v", input, err) + continue + } + if !s.IsDefault { + t.Errorf("Resolve(%q): category alias must resolve to a default status, got is_default=false (%q)", input, s.Name) + } + if !s.SystemKey.Valid || s.SystemKey.String != wantKey { + t.Errorf("Resolve(%q): want default system_key %q, got %v", input, wantKey, s.SystemKey) + } + } +} + +func TestResolveLegacyAlias(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + cases := map[string]struct{ key, category string }{ + "in_review": {"in_review", "in_progress"}, + "blocked": {"blocked", "in_progress"}, + "BLOCKED": {"blocked", "in_progress"}, + } + for input, want := range cases { + s, err := Resolve(ctx, q, wsID, input) + if err != nil { + t.Errorf("Resolve(%q): %v", input, err) + continue + } + if !s.SystemKey.Valid || s.SystemKey.String != want.key { + t.Errorf("Resolve(%q): want system_key %q, got %v", input, want.key, s.SystemKey) + } + if s.Category != want.category { + t.Errorf("Resolve(%q): want category %q, got %q", input, want.category, s.Category) + } + if s.IsDefault { + t.Errorf("Resolve(%q): in_review/blocked are non-default statuses, got is_default=true", input) + } + } +} + +func TestResolveExactName(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + // Display names render with spaces; the underscore alias is a separate path. + cases := map[string]string{ // name input -> expected system_key + "In Progress": "in_progress", + "in progress": "in_progress", // case-insensitive + "In Review": "in_review", + "Done": "done", + } + for input, wantKey := range cases { + s, err := Resolve(ctx, q, wsID, input) + if err != nil { + t.Errorf("Resolve(%q): %v", input, err) + continue + } + if !s.SystemKey.Valid || s.SystemKey.String != wantKey { + t.Errorf("Resolve(%q): want system_key %q, got %v", input, wantKey, s.SystemKey) + } + } +} + +// TestResolveCategoryAliasFollowsRenamedDefault is plan example A: renaming the +// default Todo status must not break the `todo` alias, and a non-default custom +// Todo status is reachable only by its exact name. +func TestResolveCategoryAliasFollowsRenamedDefault(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + if _, err := testPool.Exec(ctx, + "UPDATE issue_status SET name = $2 WHERE workspace_id = $1 AND system_key = 'todo'", + wsID, "待排期"); err != nil { + t.Fatalf("rename todo default: %v", err) + } + if _, err := testPool.Exec(ctx, + `INSERT INTO issue_status (workspace_id, name, description, icon, color, category, system_key, is_default, position) + VALUES ($1, '需求澄清', '', 'todo', 'muted-foreground', 'todo', NULL, FALSE, 10)`, + wsID); err != nil { + t.Fatalf("insert custom todo status: %v", err) + } + + // `todo` alias still lands on the (renamed) default. + got, err := Resolve(ctx, q, wsID, "todo") + if err != nil { + t.Fatalf("Resolve(todo): %v", err) + } + if got.Name != "待排期" || !got.IsDefault { + t.Fatalf("Resolve(todo): want renamed default 待排期, got name=%q is_default=%v", got.Name, got.IsDefault) + } + + // The custom non-default status is reachable by exact name only. + got, err = Resolve(ctx, q, wsID, "需求澄清") + if err != nil { + t.Fatalf("Resolve(需求澄清): %v", err) + } + if got.Name != "需求澄清" || got.IsDefault { + t.Fatalf("Resolve(需求澄清): want custom non-default status, got name=%q is_default=%v", got.Name, got.IsDefault) + } + + // And the renamed default is reachable by its new exact name too. + got, err = Resolve(ctx, q, wsID, "待排期") + if err != nil { + t.Fatalf("Resolve(待排期): %v", err) + } + if !got.SystemKey.Valid || got.SystemKey.String != "todo" { + t.Fatalf("Resolve(待排期): want the todo built-in, got system_key=%v", got.SystemKey) + } +} + +func TestResolveUnknownReturnsInvalidStatusError(t *testing.T) { + ctx := context.Background() + wsID, q := seededWorkspace(ctx, t) + + _, err := Resolve(ctx, q, wsID, "no-such-status") + if err == nil { + t.Fatal("Resolve(no-such-status): want error, got nil") + } + var invalid *InvalidStatusError + if !errors.As(err, &invalid) { + t.Fatalf("Resolve(no-such-status): want *InvalidStatusError, got %T: %v", err, err) + } + if len(invalid.CategoryAliases) != 5 { + t.Errorf("want 5 category aliases in error, got %v", invalid.CategoryAliases) + } + if len(invalid.LegacyAliases) != 2 { + t.Errorf("want 2 legacy aliases in error, got %v", invalid.LegacyAliases) + } + if len(invalid.Names) != len(wantSystemStatuses) { + t.Errorf("want %d names in error, got %d (%v)", len(wantSystemStatuses), len(invalid.Names), invalid.Names) + } + + // Empty input is also invalid, not a silent match. + if _, err := Resolve(ctx, q, wsID, " "); !errors.As(err, &invalid) { + t.Errorf("Resolve(blank): want *InvalidStatusError, got %v", err) + } +} + +func TestIsReservedStatusToken(t *testing.T) { + reserved := []string{"backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled", " TODO ", "In_Review"} + for _, tok := range reserved { + if !IsReservedStatusToken(tok) { + t.Errorf("IsReservedStatusToken(%q) = false, want true", tok) + } + } + notReserved := []string{"待排期", "in review", "in progress", "custom", ""} + for _, tok := range notReserved { + if IsReservedStatusToken(tok) { + t.Errorf("IsReservedStatusToken(%q) = true, want false", tok) + } + } +}