feat(issue-status): alias resolver for Category/legacy/name inputs (MUL-4809)

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 <github@multica.ai>
This commit is contained in:
Bohan-J
2026-07-16 13:11:54 +08:00
parent 0c8479a02a
commit aecfeaa404
2 changed files with 343 additions and 0 deletions

View File

@@ -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])
}

View File

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