mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
153
server/internal/issuestatus/resolver.go
Normal file
153
server/internal/issuestatus/resolver.go
Normal 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])
|
||||
}
|
||||
190
server/internal/issuestatus/resolver_test.go
Normal file
190
server/internal/issuestatus/resolver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user