mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 10:59:31 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6c5d2ac9 | ||
|
|
64555c9aa0 |
@@ -442,8 +442,10 @@ type actorDisplayLookup struct {
|
||||
type actorDisplayLookupState struct {
|
||||
members map[string]string
|
||||
agents map[string]string
|
||||
squads map[string]string
|
||||
membersLoaded bool
|
||||
agentsLoaded bool
|
||||
squadsLoaded bool
|
||||
}
|
||||
|
||||
func loadActorDisplayLookup(ctx context.Context, client *cli.APIClient) actorDisplayLookup {
|
||||
@@ -493,6 +495,25 @@ func (l actorDisplayLookup) loadAgents() {
|
||||
}
|
||||
}
|
||||
|
||||
func (l actorDisplayLookup) loadSquads() {
|
||||
if l.state == nil || l.state.squadsLoaded {
|
||||
return
|
||||
}
|
||||
l.state.squadsLoaded = true
|
||||
l.state.squads = map[string]string{}
|
||||
if l.client == nil || l.client.WorkspaceID == "" {
|
||||
return
|
||||
}
|
||||
var squads []map[string]any
|
||||
if err := l.client.GetJSON(l.ctx, "/api/squads", &squads); err == nil {
|
||||
for _, s := range squads {
|
||||
if id := strVal(s, "id"); id != "" {
|
||||
l.state.squads[id] = strVal(s, "name")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l actorDisplayLookup) actor(actorType, id string) string {
|
||||
if actorType == "" || id == "" {
|
||||
return ""
|
||||
@@ -512,6 +533,13 @@ func (l actorDisplayLookup) actor(actorType, id string) string {
|
||||
return "agent:" + name
|
||||
}
|
||||
}
|
||||
case "squad":
|
||||
l.loadSquads()
|
||||
if l.state != nil && l.state.squads != nil {
|
||||
if name := l.state.squads[id]; name != "" {
|
||||
return "squad:" + name
|
||||
}
|
||||
}
|
||||
}
|
||||
return actorType + ":" + id
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ var issueUpdateCmd = &cobra.Command{
|
||||
|
||||
var issueAssignCmd = &cobra.Command{
|
||||
Use: "assign <id>",
|
||||
Short: "Assign an issue to a member or agent",
|
||||
Short: "Assign an issue to a member, agent, or squad",
|
||||
Args: exactArgs(1),
|
||||
RunE: runIssueAssign,
|
||||
}
|
||||
@@ -242,8 +242,8 @@ func init() {
|
||||
issueListCmd.Flags().Bool("full-id", false, "Show full UUIDs in table output")
|
||||
issueListCmd.Flags().String("status", "", "Filter by status")
|
||||
issueListCmd.Flags().String("priority", "", "Filter by priority")
|
||||
issueListCmd.Flags().String("assignee", "", "Filter by assignee name (member or agent; fuzzy match)")
|
||||
issueListCmd.Flags().String("assignee-id", "", "Filter by assignee UUID (mutually exclusive with --assignee)")
|
||||
issueListCmd.Flags().String("assignee", "", "Filter by assignee name (member, agent, or squad; fuzzy match)")
|
||||
issueListCmd.Flags().String("assignee-id", "", "Filter by assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
|
||||
issueListCmd.Flags().String("project", "", "Filter by project ID")
|
||||
issueListCmd.Flags().Int("limit", 50, "Maximum number of issues to return")
|
||||
issueListCmd.Flags().Int("offset", 0, "Number of issues to skip (for pagination)")
|
||||
@@ -258,8 +258,8 @@ func init() {
|
||||
issueCreateCmd.Flags().String("description-file", "", "Read issue description from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
|
||||
issueCreateCmd.Flags().String("status", "", "Issue status")
|
||||
issueCreateCmd.Flags().String("priority", "", "Issue priority")
|
||||
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent; fuzzy match)")
|
||||
issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID (mutually exclusive with --assignee)")
|
||||
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member, agent, or squad; fuzzy match)")
|
||||
issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
|
||||
issueCreateCmd.Flags().String("parent", "", "Parent issue ID")
|
||||
issueCreateCmd.Flags().String("project", "", "Project ID")
|
||||
issueCreateCmd.Flags().String("due-date", "", "Due date (RFC3339 format)")
|
||||
@@ -273,8 +273,8 @@ func init() {
|
||||
issueUpdateCmd.Flags().String("description-file", "", "Read new description from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
|
||||
issueUpdateCmd.Flags().String("status", "", "New status")
|
||||
issueUpdateCmd.Flags().String("priority", "", "New priority")
|
||||
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent; fuzzy match)")
|
||||
issueUpdateCmd.Flags().String("assignee-id", "", "New assignee UUID (mutually exclusive with --assignee)")
|
||||
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member, agent, or squad; fuzzy match)")
|
||||
issueUpdateCmd.Flags().String("assignee-id", "", "New assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
|
||||
issueUpdateCmd.Flags().String("project", "", "Project ID")
|
||||
issueUpdateCmd.Flags().String("due-date", "", "New due date (RFC3339 format)")
|
||||
issueUpdateCmd.Flags().String("parent", "", "Parent issue ID (use --parent \"\" to clear)")
|
||||
@@ -284,8 +284,8 @@ func init() {
|
||||
issueStatusCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// issue assign
|
||||
issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent; fuzzy match)")
|
||||
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID (mutually exclusive with --to)")
|
||||
issueAssignCmd.Flags().String("to", "", "Assignee name (member, agent, or squad; fuzzy match)")
|
||||
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --to)")
|
||||
issueAssignCmd.Flags().Bool("unassign", false, "Remove current assignee")
|
||||
issueAssignCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
@@ -362,7 +362,7 @@ func runIssueList(cmd *cobra.Command, _ []string) error {
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
params.Set("limit", fmt.Sprintf("%d", v))
|
||||
}
|
||||
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
|
||||
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
@@ -554,7 +554,7 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||
if v, _ := cmd.Flags().GetString("due-date"); v != "" {
|
||||
body["due_date"] = v
|
||||
}
|
||||
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
|
||||
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
@@ -691,7 +691,7 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
|
||||
body["due_date"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("assignee") || cmd.Flags().Changed("assignee-id") {
|
||||
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
|
||||
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
@@ -770,7 +770,7 @@ func runIssueAssign(cmd *cobra.Command, args []string) error {
|
||||
body["assignee_type"] = nil
|
||||
body["assignee_id"] = nil
|
||||
} else {
|
||||
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id")
|
||||
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id", issueAssigneeKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
@@ -1286,7 +1286,7 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
|
||||
|
||||
body := map[string]any{}
|
||||
userName, _ := cmd.Flags().GetString("user")
|
||||
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id")
|
||||
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id", memberOrAgentKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve user: %w", resolveErr)
|
||||
}
|
||||
@@ -1325,19 +1325,58 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type assigneeMatch struct {
|
||||
Type string // "member" or "agent"
|
||||
ID string // user_id for members, agent id for agents
|
||||
Type string // "member", "agent", or "squad"
|
||||
ID string // user_id for members, agent id for agents, squad id for squads
|
||||
Name string
|
||||
}
|
||||
|
||||
func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (string, string, error) {
|
||||
// assigneeKinds is the set of entity types a given flag is allowed to resolve
|
||||
// to. Issue assignees accept all three (`issueAssigneeKinds`), while
|
||||
// project lead and issue subscribers are member-or-agent only
|
||||
// (`memberOrAgentKinds`) — the DB CHECK on `project.lead_type` and the
|
||||
// `isWorkspaceEntity` switch in the subscriber handler both reject `squad`,
|
||||
// so resolving to (squad, ...) for those callers would surface as a 500 /
|
||||
// 403 instead of a clean CLI-side resolution error (MUL-2165 follow-up).
|
||||
type assigneeKinds struct {
|
||||
member, agent, squad bool
|
||||
}
|
||||
|
||||
var (
|
||||
issueAssigneeKinds = assigneeKinds{member: true, agent: true, squad: true}
|
||||
memberOrAgentKinds = assigneeKinds{member: true, agent: true}
|
||||
)
|
||||
|
||||
func (k assigneeKinds) describe() string {
|
||||
parts := make([]string, 0, 3)
|
||||
if k.member {
|
||||
parts = append(parts, "member")
|
||||
}
|
||||
if k.agent {
|
||||
parts = append(parts, "agent")
|
||||
}
|
||||
if k.squad {
|
||||
parts = append(parts, "squad")
|
||||
}
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return "<none>"
|
||||
case 1:
|
||||
return parts[0]
|
||||
case 2:
|
||||
return parts[0] + " or " + parts[1]
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], ", ") + ", or " + parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAssignee(ctx context.Context, client *cli.APIClient, name string, kinds assigneeKinds) (string, string, error) {
|
||||
if client.WorkspaceID == "" {
|
||||
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
|
||||
}
|
||||
|
||||
input := strings.TrimSpace(name)
|
||||
if input == "" {
|
||||
return "", "", fmt.Errorf("no member or agent found matching %q", name)
|
||||
return "", "", fmt.Errorf("no %s found matching %q", kinds.describe(), name)
|
||||
}
|
||||
inputLower := strings.ToLower(input)
|
||||
|
||||
@@ -1349,6 +1388,7 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s
|
||||
// 3. substringMatches — preserves the existing partial-name UX.
|
||||
var idMatches, exactMatches, substringMatches []assigneeMatch
|
||||
var errs []error
|
||||
var fetchAttempts int
|
||||
|
||||
classify := func(entityType, id, displayName string) {
|
||||
match := assigneeMatch{Type: entityType, ID: id, Name: displayName}
|
||||
@@ -1366,29 +1406,61 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s
|
||||
}
|
||||
|
||||
// Search members.
|
||||
var members []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members); err != nil {
|
||||
errs = append(errs, fmt.Errorf("fetch members: %w", err))
|
||||
} else {
|
||||
for _, m := range members {
|
||||
classify("member", strVal(m, "user_id"), strVal(m, "name"))
|
||||
if kinds.member {
|
||||
fetchAttempts++
|
||||
var members []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members); err != nil {
|
||||
errs = append(errs, fmt.Errorf("fetch members: %w", err))
|
||||
} else {
|
||||
for _, m := range members {
|
||||
classify("member", strVal(m, "user_id"), strVal(m, "name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search agents.
|
||||
var agents []map[string]any
|
||||
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
if err := client.GetJSON(ctx, agentPath, &agents); err != nil {
|
||||
errs = append(errs, fmt.Errorf("fetch agents: %w", err))
|
||||
} else {
|
||||
for _, a := range agents {
|
||||
classify("agent", strVal(a, "id"), strVal(a, "name"))
|
||||
if kinds.agent {
|
||||
fetchAttempts++
|
||||
var agents []map[string]any
|
||||
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
if err := client.GetJSON(ctx, agentPath, &agents); err != nil {
|
||||
errs = append(errs, fmt.Errorf("fetch agents: %w", err))
|
||||
} else {
|
||||
for _, a := range agents {
|
||||
classify("agent", strVal(a, "id"), strVal(a, "name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If both fetches failed, report the errors instead of a misleading "not found".
|
||||
if len(errs) == 2 {
|
||||
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", errs[0], errs[1])
|
||||
// Search squads. The platform allows issues to be assigned to a squad
|
||||
// (the leader agent then coordinates delegation), so squad names must
|
||||
// resolve here too for issue-assignee callers — otherwise a user saying
|
||||
// "assign to <SquadName>" silently falls through and the autopilot
|
||||
// prompt emits "Unrecognized assignee: <SquadName>" (MUL-2165). Callers
|
||||
// whose target schema is member-or-agent only (project lead, subscriber)
|
||||
// must opt out via `kinds.squad = false`.
|
||||
if kinds.squad {
|
||||
fetchAttempts++
|
||||
var squads []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/squads", &squads); err != nil {
|
||||
errs = append(errs, fmt.Errorf("fetch squads: %w", err))
|
||||
} else {
|
||||
for _, s := range squads {
|
||||
if strVal(s, "archived_at") != "" {
|
||||
continue
|
||||
}
|
||||
classify("squad", strVal(s, "id"), strVal(s, "name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If every fetch failed, report the errors instead of a misleading "not found".
|
||||
if fetchAttempts > 0 && len(errs) == fetchAttempts {
|
||||
msgs := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
msgs[i] = e.Error()
|
||||
}
|
||||
return "", "", fmt.Errorf("failed to resolve assignee: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
|
||||
for _, bucket := range [][]assigneeMatch{idMatches, exactMatches, substringMatches} {
|
||||
@@ -1401,7 +1473,7 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (s
|
||||
return "", "", ambiguousAssigneeError(input, bucket)
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("no member or agent found matching %q", input)
|
||||
return "", "", fmt.Errorf("no %s found matching %q", kinds.describe(), input)
|
||||
}
|
||||
|
||||
func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
|
||||
@@ -1413,12 +1485,13 @@ func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
|
||||
}
|
||||
|
||||
// resolveAssigneeByID strictly resolves a canonical UUID to (assignee_type,
|
||||
// assignee_id) by looking it up against the workspace's members and agents.
|
||||
// It is the deterministic counterpart to resolveAssignee: callers that already
|
||||
// hold a UUID (e.g. agents reading IDs from `multica workspace members
|
||||
// --output json`) should use this instead of round-tripping through name
|
||||
// matching, which can be ambiguous in workspaces with overlapping names.
|
||||
func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string) (string, string, error) {
|
||||
// assignee_id) by looking it up against the workspace's members, agents, and
|
||||
// (when allowed) squads. It is the deterministic counterpart to
|
||||
// resolveAssignee: callers that already hold a UUID (e.g. agents reading IDs
|
||||
// from `multica workspace members --output json`) should use this instead of
|
||||
// round-tripping through name matching, which can be ambiguous in workspaces
|
||||
// with overlapping names.
|
||||
func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string, kinds assigneeKinds) (string, string, error) {
|
||||
if client.WorkspaceID == "" {
|
||||
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
|
||||
}
|
||||
@@ -1428,14 +1501,40 @@ func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string)
|
||||
}
|
||||
|
||||
var members []map[string]any
|
||||
memberErr := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
|
||||
var memberErr error
|
||||
if kinds.member {
|
||||
memberErr = client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
|
||||
}
|
||||
|
||||
var agents []map[string]any
|
||||
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
agentErr := client.GetJSON(ctx, agentPath, &agents)
|
||||
var agentErr error
|
||||
if kinds.agent {
|
||||
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
agentErr = client.GetJSON(ctx, agentPath, &agents)
|
||||
}
|
||||
|
||||
if memberErr != nil && agentErr != nil {
|
||||
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", memberErr, agentErr)
|
||||
var squads []map[string]any
|
||||
var squadErr error
|
||||
if kinds.squad {
|
||||
squadErr = client.GetJSON(ctx, "/api/squads", &squads)
|
||||
}
|
||||
|
||||
allFailed := true
|
||||
hasFetch := false
|
||||
for _, pair := range []struct {
|
||||
enabled bool
|
||||
err error
|
||||
}{{kinds.member, memberErr}, {kinds.agent, agentErr}, {kinds.squad, squadErr}} {
|
||||
if !pair.enabled {
|
||||
continue
|
||||
}
|
||||
hasFetch = true
|
||||
if pair.err == nil {
|
||||
allFailed = false
|
||||
}
|
||||
}
|
||||
if hasFetch && allFailed {
|
||||
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v; %v", memberErr, agentErr, squadErr)
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
@@ -1448,23 +1547,29 @@ func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string)
|
||||
return "agent", strVal(a, "id"), nil
|
||||
}
|
||||
}
|
||||
for _, s := range squads {
|
||||
if strings.EqualFold(strVal(s, "id"), input) {
|
||||
return "squad", strVal(s, "id"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no member or agent found with ID %q", input)
|
||||
return "", "", fmt.Errorf("no %s found with ID %q", kinds.describe(), input)
|
||||
}
|
||||
|
||||
// pickAssigneeFromFlags reads a (name-flag, id-flag) pair off cmd and resolves
|
||||
// it to (assignee_type, assignee_id). The third return reports whether either
|
||||
// flag was *explicitly set*; callers use it to decide whether to write
|
||||
// `assignee_*` into the request body. The two flags are mutually exclusive —
|
||||
// passing both is rejected up-front so a script that accidentally sets both
|
||||
// never silently applies one over the other.
|
||||
// it to (assignee_type, assignee_id), restricted to the entity types in
|
||||
// kinds. The third return reports whether either flag was *explicitly set*;
|
||||
// callers use it to decide whether to write `assignee_*` into the request
|
||||
// body. The two flags are mutually exclusive — passing both is rejected
|
||||
// up-front so a script that accidentally sets both never silently applies one
|
||||
// over the other.
|
||||
//
|
||||
// Presence is detected via Flags().Changed (not value-emptiness): a script
|
||||
// that interpolates an empty env var (`--assignee-id "$MAYBE_UUID"`) must
|
||||
// fail loudly through resolveAssignee/resolveAssigneeByID rather than silently
|
||||
// degrade to "no filter / unassigned / subscribe caller", which would defeat
|
||||
// the strict-UUID guarantee the new flags exist for.
|
||||
func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobra.Command, nameFlag, idFlag string) (string, string, bool, error) {
|
||||
func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobra.Command, nameFlag, idFlag string, kinds assigneeKinds) (string, string, bool, error) {
|
||||
nameSet := cmd.Flags().Changed(nameFlag)
|
||||
idSet := cmd.Flags().Changed(idFlag)
|
||||
if nameSet && idSet {
|
||||
@@ -1472,7 +1577,7 @@ func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobr
|
||||
}
|
||||
if idSet {
|
||||
idVal, _ := cmd.Flags().GetString(idFlag)
|
||||
t, i, err := resolveAssigneeByID(ctx, client, idVal)
|
||||
t, i, err := resolveAssigneeByID(ctx, client, idVal, kinds)
|
||||
if err != nil {
|
||||
return "", "", true, err
|
||||
}
|
||||
@@ -1480,7 +1585,7 @@ func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobr
|
||||
}
|
||||
if nameSet {
|
||||
name, _ := cmd.Flags().GetString(nameFlag)
|
||||
t, i, err := resolveAssignee(ctx, client, name)
|
||||
t, i, err := resolveAssignee(ctx, client, name, kinds)
|
||||
if err != nil {
|
||||
return "", "", true, err
|
||||
}
|
||||
|
||||
@@ -207,8 +207,10 @@ func TestFormatAssignee(t *testing.T) {
|
||||
state: &actorDisplayLookupState{
|
||||
members: map[string]string{"abcdefgh-1234": "Alice"},
|
||||
agents: map[string]string{"xyz": "CodeBot"},
|
||||
squads: map[string]string{"sq-1": "Super Human"},
|
||||
membersLoaded: true,
|
||||
agentsLoaded: true,
|
||||
squadsLoaded: true,
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
@@ -221,6 +223,7 @@ func TestFormatAssignee(t *testing.T) {
|
||||
{"no id", map[string]any{"assignee_type": "member"}, ""},
|
||||
{"member", map[string]any{"assignee_type": "member", "assignee_id": "abcdefgh-1234"}, "member:Alice"},
|
||||
{"agent", map[string]any{"assignee_type": "agent", "assignee_id": "xyz"}, "agent:CodeBot"},
|
||||
{"squad", map[string]any{"assignee_type": "squad", "assignee_id": "sq-1"}, "squad:Super Human"},
|
||||
{"unknown fallback", map[string]any{"assignee_type": "agent", "assignee_id": "missing"}, "agent:missing"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -572,6 +575,9 @@ func TestResolveAssignee(t *testing.T) {
|
||||
agentsResp := []map[string]any{
|
||||
{"id": "agent-3333", "name": "CodeBot"},
|
||||
}
|
||||
squadsResp := []map[string]any{
|
||||
{"id": "squad-4444", "name": "Super Human"},
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
@@ -579,6 +585,8 @@ func TestResolveAssignee(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(membersResp)
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
json.NewEncoder(w).Encode(squadsResp)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -589,7 +597,7 @@ func TestResolveAssignee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("exact match member", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -599,7 +607,7 @@ func TestResolveAssignee(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("case-insensitive substring", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "bob")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "bob", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -609,7 +617,7 @@ func TestResolveAssignee(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("match agent", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "codebot")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "codebot", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -618,8 +626,31 @@ func TestResolveAssignee(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// MUL-2165: squad names must resolve to (squad, <id>) so the autopilot
|
||||
// quick-create prompt can route work to a squad (e.g. "Super Human")
|
||||
// instead of falling through to "Unrecognized assignee".
|
||||
t.Run("match squad by exact name", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "Super Human", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "squad" || aID != "squad-4444" {
|
||||
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("match squad by case-insensitive substring", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "super", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "squad" || aID != "squad-4444" {
|
||||
t.Errorf("got (%q, %q), want (squad, squad-4444)", aType, aID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match", func(t *testing.T) {
|
||||
_, _, err := resolveAssignee(ctx, client, "nobody")
|
||||
_, _, err := resolveAssignee(ctx, client, "nobody", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no match")
|
||||
}
|
||||
@@ -628,7 +659,7 @@ func TestResolveAssignee(t *testing.T) {
|
||||
t.Run("ambiguous", func(t *testing.T) {
|
||||
// Both "Alice Smith" and "Bob Jones" contain a space — but let's use a broader query
|
||||
// "e" matches "Alice Smith" and "Bob Jones" and "CodeBot"
|
||||
_, _, err := resolveAssignee(ctx, client, "o")
|
||||
_, _, err := resolveAssignee(ctx, client, "o", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ambiguous match")
|
||||
}
|
||||
@@ -639,13 +670,92 @@ func TestResolveAssignee(t *testing.T) {
|
||||
|
||||
t.Run("missing workspace ID", func(t *testing.T) {
|
||||
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
|
||||
_, _, err := resolveAssignee(ctx, noWSClient, "alice")
|
||||
_, _, err := resolveAssignee(ctx, noWSClient, "alice", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing workspace ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveAssigneeRespectsKinds covers the MUL-2165 follow-up: callers
|
||||
// whose target schema is member-or-agent-only (project.lead_type DB CHECK
|
||||
// at server/migrations/034_projects.up.sql:10, and the subscriber handler's
|
||||
// isWorkspaceEntity switch at server/internal/handler/handler.go:414) must
|
||||
// be able to opt out of squad resolution. Without this, "--lead <SquadName>"
|
||||
// would return (squad, ...) and the request would 500/403 server-side
|
||||
// instead of failing with a clean CLI-side resolution error.
|
||||
func TestResolveAssigneeRespectsKinds(t *testing.T) {
|
||||
membersResp := []map[string]any{
|
||||
{"user_id": "user-1111", "name": "Alice"},
|
||||
}
|
||||
agentsResp := []map[string]any{
|
||||
{"id": "agent-3333", "name": "CodeBot"},
|
||||
}
|
||||
squadsResp := []map[string]any{
|
||||
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
||||
}
|
||||
|
||||
var squadsHits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/workspaces/ws-1/members":
|
||||
json.NewEncoder(w).Encode(membersResp)
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
squadsHits++
|
||||
json.NewEncoder(w).Encode(squadsResp)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("memberOrAgentKinds skips the /api/squads fetch entirely", func(t *testing.T) {
|
||||
before := squadsHits
|
||||
_, _, _ = resolveAssignee(ctx, client, "Alice", memberOrAgentKinds)
|
||||
if squadsHits != before {
|
||||
t.Errorf("expected memberOrAgentKinds to skip /api/squads, but it was called %d time(s)", squadsHits-before)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("memberOrAgentKinds rejects a squad name with a member-or-agent-only error", func(t *testing.T) {
|
||||
_, _, err := resolveAssignee(ctx, client, "Super Human", memberOrAgentKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no member or agent") {
|
||||
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "squad") {
|
||||
t.Errorf("error must not mention squad when squads are not allowed, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("memberOrAgentKinds rejects a squad UUID via the strict resolver", func(t *testing.T) {
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "ccccccc1-2222-3333-4444-555555555555", memberOrAgentKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no member or agent") {
|
||||
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issueAssigneeKinds still resolves the same squad name (control)", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "Super Human", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "squad" || aID != "ccccccc1-2222-3333-4444-555555555555" {
|
||||
t.Errorf("got (%q, %q), want (squad, ccccccc1-...)", aType, aID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveAssigneeExactMatchWins covers the substring-collision scenario from
|
||||
// multica-ai/multica#1620: when one name is a substring of another (e.g.
|
||||
// "reviewer" vs "peer-reviewer"), an exact match on the shorter name must
|
||||
@@ -661,6 +771,8 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
|
||||
json.NewEncoder(w).Encode([]map[string]any{})
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
json.NewEncoder(w).Encode([]map[string]any{})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -671,7 +783,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("exact shorter name resolves to shorter agent", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "reviewer")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "reviewer", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -681,7 +793,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("exact longer name still resolves unambiguously", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -691,7 +803,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("exact match is case-insensitive and tolerates whitespace", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ")
|
||||
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -703,7 +815,7 @@ func TestResolveAssigneeExactMatchWins(t *testing.T) {
|
||||
t.Run("substring-only input falls back and stays ambiguous", func(t *testing.T) {
|
||||
// "review" matches both agents via substring and neither via exact name,
|
||||
// so the existing ambiguity error is preserved.
|
||||
_, _, err := resolveAssignee(ctx, client, "review")
|
||||
_, _, err := resolveAssignee(ctx, client, "review", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ambiguous substring match")
|
||||
}
|
||||
@@ -724,12 +836,17 @@ func TestResolveAssigneeByID(t *testing.T) {
|
||||
{"id": "f656eab8-1111-1111-1111-111111111111", "name": "reviewer"},
|
||||
{"id": "9b0ff9a2-2222-2222-2222-222222222222", "name": "peer-reviewer"},
|
||||
}
|
||||
squadsResp := []map[string]any{
|
||||
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/workspaces/ws-1/members":
|
||||
json.NewEncoder(w).Encode(membersResp)
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
json.NewEncoder(w).Encode(squadsResp)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -740,7 +857,7 @@ func TestResolveAssigneeByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("full UUID resolves agent", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "f656eab8-1111-1111-1111-111111111111")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "f656eab8-1111-1111-1111-111111111111", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -750,7 +867,7 @@ func TestResolveAssigneeByID(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("8-char ShortID resolves agent", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "f656eab8")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "f656eab8", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -760,7 +877,7 @@ func TestResolveAssigneeByID(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("uppercase ShortID still resolves", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -770,7 +887,7 @@ func TestResolveAssigneeByID(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ShortID resolves a member", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa")
|
||||
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -792,12 +909,17 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
|
||||
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
|
||||
{"id": "192b9cca-2222-2222-2222-222222222222", "name": "Open Claw - J"},
|
||||
}
|
||||
squadsResp := []map[string]any{
|
||||
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/workspaces/ws-1/members":
|
||||
json.NewEncoder(w).Encode(membersResp)
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
json.NewEncoder(w).Encode(squadsResp)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -811,7 +933,7 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
|
||||
// This is the MUL-1254 scenario: agent "J" is unreachable by name
|
||||
// because every other agent has "J" in it. UUID lookup must
|
||||
// deterministically pick the right one.
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -821,7 +943,7 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("uppercase UUID is normalized", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D")
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -831,7 +953,7 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("UUID resolves a member", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111")
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -840,8 +962,21 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// MUL-2165: --assignee-id <squad-uuid> must resolve to (squad, <id>) so
|
||||
// scripts that read the squad list and pin its UUID can assign work to a
|
||||
// squad in a single deterministic call.
|
||||
t.Run("UUID resolves a squad", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "ccccccc1-2222-3333-4444-555555555555", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "squad" || aID != "ccccccc1-2222-3333-4444-555555555555" {
|
||||
t.Errorf("got (%q, %q), want squad Super Human", aType, aID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-UUID input is rejected without name fallback", func(t *testing.T) {
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "Alice")
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "Alice", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-UUID input")
|
||||
}
|
||||
@@ -851,25 +986,25 @@ func TestResolveAssigneeByIDStrict(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("UUID prefix (ShortID) is rejected — strict mode requires canonical form", func(t *testing.T) {
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7")
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ShortID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("well-formed UUID with no matching entity errors", func(t *testing.T) {
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111")
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing entity")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no member or agent") {
|
||||
if !strings.Contains(err.Error(), "no member, agent, or squad") {
|
||||
t.Errorf("expected not-found error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing workspace ID", func(t *testing.T) {
|
||||
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
|
||||
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
|
||||
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing workspace ID")
|
||||
}
|
||||
@@ -893,6 +1028,8 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
json.NewEncoder(w).Encode(membersResp)
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
json.NewEncoder(w).Encode([]map[string]any{})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -910,7 +1047,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("neither flag set returns hasValue=false", func(t *testing.T) {
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id")
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
@@ -922,7 +1059,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
t.Run("name flag uses fuzzy resolver", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee", "Alice")
|
||||
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
|
||||
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
|
||||
t.Errorf("got (%q, %q, %v, %v), want Alice", typ, id, has, err)
|
||||
}
|
||||
@@ -931,7 +1068,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
t.Run("id flag uses strict resolver", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee-id", "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
|
||||
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
|
||||
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
||||
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
|
||||
}
|
||||
@@ -941,7 +1078,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee", "Alice")
|
||||
_ = c.Flags().Set("assignee-id", "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
|
||||
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
|
||||
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected mutually-exclusive error")
|
||||
}
|
||||
@@ -959,7 +1096,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
t.Run("explicit empty --assignee-id surfaces as UUID error, not silent skip", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee-id", "")
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected UUID error for explicit empty assignee-id")
|
||||
}
|
||||
@@ -974,7 +1111,7 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
t.Run("explicit empty --assignee surfaces as not-found, not silent skip", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee", "")
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected resolver error for explicit empty assignee")
|
||||
}
|
||||
@@ -987,13 +1124,106 @@ func TestPickAssigneeFromFlags(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee", "")
|
||||
_ = c.Flags().Set("assignee-id", "")
|
||||
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
|
||||
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected mutually-exclusive error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPickAssigneeFromFlagsMemberOrAgentKinds is the call-site regression
|
||||
// for the MUL-2165 follow-up. Subscriber add/remove and project lead pass
|
||||
// memberOrAgentKinds because their target schema rejects squads
|
||||
// (subscriber: server/internal/handler/handler.go:414;
|
||||
// project: server/migrations/034_projects.up.sql:10). Without this gating,
|
||||
// `multica issue subscriber add --user "<SquadName>"` or
|
||||
// `multica project create --lead "<SquadName>"` would resolve to
|
||||
// (squad, ...) and surface as a 500/403 server-side instead of a clean
|
||||
// CLI-side resolution error.
|
||||
func TestPickAssigneeFromFlagsMemberOrAgentKinds(t *testing.T) {
|
||||
membersResp := []map[string]any{
|
||||
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
|
||||
}
|
||||
agentsResp := []map[string]any{
|
||||
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
|
||||
}
|
||||
squadsResp := []map[string]any{
|
||||
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
|
||||
}
|
||||
|
||||
var squadsHits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/workspaces/ws-1/members":
|
||||
json.NewEncoder(w).Encode(membersResp)
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(agentsResp)
|
||||
case "/api/squads":
|
||||
squadsHits++
|
||||
json.NewEncoder(w).Encode(squadsResp)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
newCmd := func(nameFlag, idFlag string) *cobra.Command {
|
||||
c := &cobra.Command{Use: "test"}
|
||||
c.Flags().String(nameFlag, "", "")
|
||||
c.Flags().String(idFlag, "", "")
|
||||
return c
|
||||
}
|
||||
|
||||
t.Run("subscriber --user with a squad name is rejected without hitting /api/squads", func(t *testing.T) {
|
||||
before := squadsHits
|
||||
c := newCmd("user", "user-id")
|
||||
_ = c.Flags().Set("user", "Super Human")
|
||||
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no member or agent") {
|
||||
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
||||
}
|
||||
if squadsHits != before {
|
||||
t.Errorf("memberOrAgentKinds must NOT fetch /api/squads, but it was called %d time(s)", squadsHits-before)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("subscriber --user-id with a squad UUID is rejected", func(t *testing.T) {
|
||||
c := newCmd("user", "user-id")
|
||||
_ = c.Flags().Set("user-id", "ccccccc1-2222-3333-4444-555555555555")
|
||||
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no member or agent") {
|
||||
t.Errorf("expected member-or-agent error wording, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project --lead with a member name still resolves cleanly", func(t *testing.T) {
|
||||
c := newCmd("lead", "lead-id")
|
||||
_ = c.Flags().Set("lead", "Alice")
|
||||
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
|
||||
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
|
||||
t.Errorf("got (%q, %q, %v, %v), want member Alice", typ, id, has, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project --lead with an agent name still resolves cleanly", func(t *testing.T) {
|
||||
c := newCmd("lead", "lead-id")
|
||||
_ = c.Flags().Set("lead", "J")
|
||||
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
|
||||
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
||||
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueSubscriberList(t *testing.T) {
|
||||
subscribersResp := []map[string]any{
|
||||
{
|
||||
@@ -1094,6 +1324,9 @@ func TestIssueSubscriberMutationBody(t *testing.T) {
|
||||
case "/api/agents":
|
||||
json.NewEncoder(w).Encode(tt.agents)
|
||||
return
|
||||
case "/api/squads":
|
||||
json.NewEncoder(w).Encode([]map[string]any{})
|
||||
return
|
||||
}
|
||||
gotPath = r.URL.Path
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -1109,7 +1342,7 @@ func TestIssueSubscriberMutationBody(t *testing.T) {
|
||||
|
||||
body := map[string]any{}
|
||||
if tt.user != "" {
|
||||
uType, uID, err := resolveAssignee(ctx, client, tt.user)
|
||||
uType, uID, err := resolveAssignee(ctx, client, tt.user, issueAssigneeKinds)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAssignee: %v", err)
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ func runProjectCreate(cmd *cobra.Command, _ []string) error {
|
||||
body["icon"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("lead"); v != "" {
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, v, memberOrAgentKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve lead: %w", resolveErr)
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func runProjectUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
if cmd.Flags().Changed("lead") {
|
||||
v, _ := cmd.Flags().GetString("lead")
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, v, memberOrAgentKinds)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve lead: %w", resolveErr)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("**Use `--output json` for structured data.** Human table output now prints routable issue keys (for example `MUL-123`) and short UUID prefixes for workspace resources; use `--full-id` on list commands when you need canonical UUIDs.\n\n")
|
||||
b.WriteString("### Read\n")
|
||||
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
|
||||
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] [--full-id] [--output json]` — List issues in workspace (default limit: 50; table output uses routable issue keys; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true). Prefer `--assignee-id <uuid>` when scripting from `multica workspace members --output json` / `multica agent list --output json`.\n")
|
||||
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] [--full-id] [--output json]` — List issues in workspace (default limit: 50; table output uses routable issue keys; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true). Prefer `--assignee-id <uuid>` when scripting from `multica workspace members --output json` / `multica agent list --output json` / `multica squad list --output json`.\n")
|
||||
b.WriteString("- `multica issue comment list <issue-id> [--since <RFC3339>] --output json` — List all comments on an issue (server caps at 2000 rows). Use `--since` for incremental polling.\n")
|
||||
b.WriteString("- `multica issue label list <issue-id> --output json` — List labels currently attached to an issue\n")
|
||||
b.WriteString("- `multica issue subscriber list <issue-id> --output json` — List members/agents subscribed to an issue\n")
|
||||
@@ -120,6 +120,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
|
||||
b.WriteString("- `multica workspace members [workspace-id] --output json` — List workspace members (user IDs, names, roles)\n")
|
||||
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
|
||||
b.WriteString("- `multica squad list --output json` — List squads in workspace (squads are first-class assignees — assigning an issue to a squad routes it to the squad leader, who then delegates)\n")
|
||||
b.WriteString("- `multica repo checkout <url> [--ref <branch-or-sha>]` — Check out a repository into the working directory (creates a git worktree with a dedicated branch; use `--ref` for review/QA on a specific branch, tag, or commit)\n")
|
||||
b.WriteString("- `multica issue runs <issue-id> [--full-id] --output json` — List all execution runs for an issue (status, timestamps, errors); table task IDs are short prefixes unless `--full-id` is set\n")
|
||||
b.WriteString("- `multica issue run-messages <task-id> [--issue <issue-id>] [--since <seq>] --output json` — List messages for a specific execution run; full task UUIDs work directly, copied short task prefixes must be scoped with `--issue <issue-id>`\n")
|
||||
@@ -134,7 +135,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--status X] [--assignee X | --assignee-id <uuid>] [--parent <issue-id>] [--project <project-id>] [--due-date <RFC3339>] [--attachment <path>]` — Create a new issue. `--attachment` may be repeated to upload multiple files; labels and subscribers are not accepted here, attach them after create with the commands below.\n")
|
||||
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X] [--status X] [--assignee X | --assignee-id <uuid>] [--parent <issue-id>] [--project <project-id>] [--due-date <RFC3339>]` — Update one or more issue fields in a single call. Use `--parent \"\"` to clear the parent.\n")
|
||||
b.WriteString("- `multica issue status <id> <status>` — Shortcut for `issue update --status` when you only need to flip status (todo, in_progress, in_review, done, blocked, backlog, cancelled)\n")
|
||||
b.WriteString("- `multica issue assign <id> --to <name>|--to-id <uuid>` — Assign an issue to a member or agent. `--to <name>` does fuzzy name matching; pass `--to-id <uuid>` (mutually exclusive with `--to`) to assign by canonical UUID, e.g. when names overlap. Use `--unassign` to clear the assignee.\n")
|
||||
b.WriteString("- `multica issue assign <id> --to <name>|--to-id <uuid>` — Assign an issue to a member, agent, or squad. `--to <name>` does fuzzy name matching; pass `--to-id <uuid>` (mutually exclusive with `--to`) to assign by canonical UUID, e.g. when names overlap. Use `--unassign` to clear the assignee.\n")
|
||||
b.WriteString("- `multica issue label add <issue-id> <label-id>` — Attach a label to an issue (look up the label id via `multica label list`)\n")
|
||||
b.WriteString("- `multica issue label remove <issue-id> <label-id>` — Detach a label from an issue\n")
|
||||
b.WriteString("- `multica issue subscriber add <issue-id> [--user <name>|--user-id <uuid>]` — Subscribe a member or agent to issue updates (defaults to the caller when neither flag is set; the two flags are mutually exclusive)\n")
|
||||
|
||||
@@ -65,7 +65,7 @@ func buildQuickCreatePrompt(task Task) string {
|
||||
|
||||
// assignee
|
||||
b.WriteString("- **assignee**:\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` (and `multica agent list --output json` if it might be an agent) and find the matching entity by display name. On a clean unambiguous match, prefer `--assignee-id <uuid>` using the `user_id` (member) or `id` (agent) from that JSON — UUID matching is exact and robust to name collisions in workspaces with overlapping names. `--assignee <name>` (fuzzy) is acceptable as a fallback when names are unambiguous. On no match or ambiguous match, do NOT pass either flag — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json`, `multica agent list --output json`, and `multica squad list --output json` and find the matching entity by display name. Squads are first-class assignees too — a squad name (e.g. \"Super Human\") routes work to the squad leader, who then delegates. On a clean unambiguous match, prefer `--assignee-id <uuid>` using the `user_id` (member) or `id` (agent or squad) from that JSON — UUID matching is exact and robust to name collisions in workspaces with overlapping names. `--assignee <name>` (fuzzy) is acceptable as a fallback when names are unambiguous. On no match or ambiguous match, do NOT pass either flag — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
||||
agentID := ""
|
||||
agentName := ""
|
||||
if task.Agent != nil {
|
||||
|
||||
@@ -41,6 +41,24 @@ func TestBuildQuickCreatePromptRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildQuickCreatePromptAssigneeIncludesSquads locks in the MUL-2165
|
||||
// fix: the assignee-resolution rules must tell the agent to consult the
|
||||
// squad list alongside members and agents. Before this, a quick-create
|
||||
// input like "assign to <SquadName>" silently fell through to
|
||||
// "Unrecognized assignee" because squads were never queried.
|
||||
func TestBuildQuickCreatePromptAssigneeIncludesSquads(t *testing.T) {
|
||||
out := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"})
|
||||
mustContain := []string{
|
||||
"multica squad list",
|
||||
"Squads are first-class assignees",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("buildQuickCreatePrompt assignee block missing %q\n--- output ---\n%s", s, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildQuickCreatePromptProjectPinning verifies that when the user
|
||||
// pins a project in the quick-create modal, the prompt instructs the agent
|
||||
// to pass `--project <uuid>` exactly. Without this, the agent would re-read
|
||||
|
||||
Reference in New Issue
Block a user