Compare commits

..

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
3c61a0e888 Merge branch 'main' into agent/lambda/bb798d97 2026-05-13 15:59:35 +02:00
Jiayuan Zhang
bc25de8b3a test(issues): mock squadListOptions + add Assignee picker handoff test
`AssigneePicker` reads `squadListOptions` and `assigneeFrequencyOptions`
from `@multica/core/workspace/queries`. Tests that render IssueDetail
or IssueActionsDropdown without those mocks throw at the picker's
useQuery call and cascade into unrelated assertion failures — this is
what was leaving the `@multica/views` test job red on the MUL-2157 PR.

Add the missing mocks. Add a regression test that clicks the Assignee
menu item and asserts the shared picker (search input + Members group)
takes over, so a future regression to the parallel-implementation bug
this PR fixes fails loudly instead of silently.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 19:48:57 +08:00
Jiayuan Zhang
285c3fb3ca fix(issues): unify assignee menu with shared AssigneePicker (MUL-2157)
The Assignee submenu inside IssueActionsMenuItems was a parallel
implementation: no search, no squads, no agent permission check, no
archive filter, no frequency sort. The divergence was most visible from
the Inbox (where the issue detail's sidebar starts collapsed, so users
reach for the 3-dot menu).

Replace the submenu with a single menu item that closes the
surrounding dropdown / context menu and hands off to the shared
AssigneePicker popover — same component already used in the issue
detail sidebar, board cards, batch toolbar, and create-issue modal.

The picker is conditionally mounted to avoid every row in list / board
views subscribing to the members / agents / squads / frequency queries
on mount.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 19:39:00 +08:00
13 changed files with 237 additions and 601 deletions

View File

@@ -48,6 +48,18 @@ vi.mock("@multica/core/workspace/queries", () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
squadListOptions: () => ({
queryKey: ["workspaces", "ws-1", "squads"],
queryFn: () => Promise.resolve([]),
}),
assigneeFrequencyOptions: () => ({
queryKey: ["workspaces", "ws-1", "assignee-frequency"],
queryFn: () => Promise.resolve([]),
}),
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({ getActorName: (_t: string, _id: string) => "" }),
}));
vi.mock("@multica/core/pins", () => ({
@@ -158,6 +170,29 @@ describe("IssueActionsDropdown", () => {
expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument();
});
it("clicking the Assignee item opens the shared AssigneePicker popover", async () => {
render(
wrap(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
fireEvent.click(screen.getByTestId("trigger"));
fireEvent.click(await screen.findByText("Assignee"));
// The shared picker exposes a search input and renders the workspace
// member under a "Members" group — both come from `AssigneePicker`, not
// the legacy submenu (which had neither).
expect(
await screen.findByPlaceholderText("Assign to..."),
).toBeInTheDocument();
expect(await screen.findByText("Members")).toBeInTheDocument();
expect(await screen.findByText("Test User")).toBeInTheDocument();
});
it("clicking Delete issue opens the delete-confirm modal", async () => {
render(
wrap(

View File

@@ -27,20 +27,6 @@ vi.mock("@multica/core/auth", () => ({
registerAuthStore: vi.fn(),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({
queryKey: ["workspaces", "ws-1", "members"],
queryFn: () =>
Promise.resolve([
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
]),
}),
agentListOptions: () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
}));
// Mutable so individual tests can seed the pin list.
const pinListRef: { value: Array<{ item_type: string; item_id: string }> } = {
value: [],
@@ -267,12 +253,4 @@ describe("useIssueActions", () => {
expect(mockOpenModal).not.toHaveBeenCalled();
});
it("members and filtered agents are exposed on the result", async () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await waitFor(() => {
expect(result.current.members.length).toBe(1);
});
expect(result.current.members[0]!.user_id).toBe("user-1");
expect(result.current.agents).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import type { ReactElement } from "react";
import { useRef, useState, type ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
ContextMenu,
@@ -12,6 +12,7 @@ import {
IssueActionsMenuItems,
contextPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
interface IssueActionsContextMenuProps {
issue: Issue;
@@ -24,16 +25,59 @@ export function IssueActionsContextMenu({
children,
}: IssueActionsContextMenuProps) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// Right-click coordinates captured during contextmenu so the AssigneePicker
// opens where the context menu just was, instead of jumping to the row's
// top-left corner. Reset between opens; only consulted while the picker is
// mounted-open.
const clickPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const handleContextMenu = (e: React.MouseEvent) => {
clickPosRef.current = { x: e.clientX, y: e.clientY };
};
return (
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
<>
<ContextMenu>
<ContextMenuTrigger
render={children}
onContextMenu={handleContextMenu}
/>
</ContextMenuContent>
</ContextMenu>
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
/>
</ContextMenuContent>
</ContextMenu>
{/* Mount the picker only once the user actually opens it. Otherwise
every row in a list/board would subscribe to members/agents/squads
/frequency queries on mount, multiplying memory + render cost. */}
{assigneeOpen && (
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none fixed"
style={{
left: clickPosRef.current.x,
top: clickPosRef.current.y,
width: 0,
height: 0,
}}
/>
}
trigger={<span />}
align="start"
/>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import type { ReactElement } from "react";
import { useState, type ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
DropdownMenu,
@@ -12,6 +12,7 @@ import {
IssueActionsMenuItems,
dropdownPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
interface IssueActionsDropdownProps {
issue: Issue;
@@ -29,17 +30,46 @@ export function IssueActionsDropdown({
onDeletedNavigateTo,
}: IssueActionsDropdownProps) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// The outer `relative inline-flex` is the picker's anchor box: the
// absolute, pointer-events-none span inside `triggerRender` fills it, so
// the popover positions itself relative to the dropdown's 3-dot button
// without us having to thread a ref through Base UI's anchor API.
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onDeletedNavigateTo={onDeletedNavigateTo}
<span className="relative inline-flex">
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
onDeletedNavigateTo={onDeletedNavigateTo}
/>
</DropdownMenuContent>
</DropdownMenu>
{/* Mount the picker only once the user actually opens it. Otherwise
every row in a list/board would subscribe to members/agents/squads
/frequency queries on mount, multiplying memory + render cost. */}
{assigneeOpen && (
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none absolute inset-0"
/>
}
trigger={<span />}
align={align}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
);
}

View File

@@ -26,7 +26,6 @@ import {
import { issueKeys } from "@multica/core/issues/queries";
import { StatusIcon } from "../components/status-icon";
import { PriorityIcon } from "../components/priority-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import {
DropdownMenuItem,
DropdownMenuSub,
@@ -78,6 +77,11 @@ interface IssueActionsMenuItemsProps {
issue: Issue;
actions: UseIssueActionsResult;
primitives: MenuPrimitives;
/** Called when the user clicks the Assignee menu item. The parent should
* close the surrounding menu and open the shared `AssigneePicker` popover.
* Decoupled this way so the same item can drive both the dropdown
* (3-dot button) and the context menu (right-click) wrappers. */
onOpenAssignee: () => void;
/** If set, navigate here after the issue is deleted (used by the detail page). */
onDeletedNavigateTo?: string;
}
@@ -86,12 +90,11 @@ export function IssueActionsMenuItems({
issue,
actions,
primitives: P,
onOpenAssignee,
onDeletedNavigateTo,
}: IssueActionsMenuItemsProps) {
const { t } = useT("issues");
const {
members,
agents,
isPinned,
updateField,
togglePin,
@@ -184,55 +187,15 @@ export function IssueActionsMenuItems({
</P.SubContent>
</P.Sub>
{/* Assignee */}
<P.Sub>
<P.SubTrigger>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
</P.SubTrigger>
<P.SubContent>
<P.Item
onClick={() =>
updateField({ assignee_type: null, assignee_id: null })
}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
{t(($) => $.actions.unassigned)}
{!issue.assignee_type && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
{members.map((m) => (
<P.Item
key={m.user_id}
onClick={() =>
updateField({ assignee_type: "member", assignee_id: m.user_id })
}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
{issue.assignee_type === "member" &&
issue.assignee_id === m.user_id && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
))}
{agents.map((a) => (
<P.Item
key={a.id}
onClick={() =>
updateField({ assignee_type: "agent", assignee_id: a.id })
}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
))}
</P.SubContent>
</P.Sub>
{/* Assignee — closes this menu and hands off to the shared
AssigneePicker (members + agents + squads, with search and
permission checks). Keeps a single source of truth for the
assignee UX across detail sidebar, board cards, and right-click /
3-dot menus. */}
<P.Item onClick={onOpenAssignee}>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
</P.Item>
{/* Due date */}
<P.Sub>

View File

@@ -1,36 +1,22 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
Issue,
MemberWithUser,
Agent,
UpdateIssueRequest,
} from "@multica/core/types";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import {
memberListOptions,
agentListOptions,
} from "@multica/core/workspace/queries";
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
import { canAssignAgent } from "../components/pickers";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed";
export interface UseIssueActionsResult {
// Derived data for rendering menu rows
members: MemberWithUser[];
agents: Agent[];
isPinned: boolean;
// Handlers
updateField: (updates: Partial<UpdateIssueRequest>) => void;
togglePin: () => void;
copyLink: () => Promise<void>;
@@ -53,24 +39,11 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
const user = useAuthStore((s) => s.user);
const userId = user?.id;
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const currentMemberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const filteredAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, currentMemberRole),
),
[agents, userId, currentMemberRole],
);
const isPinned =
!!issue &&
pinnedItems.some(
@@ -161,8 +134,6 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
);
return {
members,
agents: filteredAgents,
isPinned,
updateField,
togglePin,

View File

@@ -442,10 +442,8 @@ 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 {
@@ -495,25 +493,6 @@ 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 ""
@@ -533,13 +512,6 @@ 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
}

View File

@@ -113,7 +113,7 @@ var issueUpdateCmd = &cobra.Command{
var issueAssignCmd = &cobra.Command{
Use: "assign <id>",
Short: "Assign an issue to a member, agent, or squad",
Short: "Assign an issue to a member or agent",
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, 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("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("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, agent, or squad; fuzzy match)")
issueCreateCmd.Flags().String("assignee-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
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("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, agent, or squad; fuzzy match)")
issueUpdateCmd.Flags().String("assignee-id", "", "New assignee UUID — member, agent, or squad (mutually exclusive with --assignee)")
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("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, agent, or squad; fuzzy match)")
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID — member, agent, or squad (mutually exclusive with --to)")
issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent; fuzzy match)")
issueAssignCmd.Flags().String("to-id", "", "Assignee UUID (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", issueAssigneeKinds)
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
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", issueAssigneeKinds)
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
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", issueAssigneeKinds)
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
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", issueAssigneeKinds)
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id")
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", memberOrAgentKinds)
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id")
if resolveErr != nil {
return fmt.Errorf("resolve user: %w", resolveErr)
}
@@ -1325,58 +1325,19 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
// ---------------------------------------------------------------------------
type assigneeMatch struct {
Type string // "member", "agent", or "squad"
ID string // user_id for members, agent id for agents, squad id for squads
Type string // "member" or "agent"
ID string // user_id for members, agent id for agents
Name string
}
// 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) {
func resolveAssignee(ctx context.Context, client *cli.APIClient, name string) (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 %s found matching %q", kinds.describe(), name)
return "", "", fmt.Errorf("no member or agent found matching %q", name)
}
inputLower := strings.ToLower(input)
@@ -1388,7 +1349,6 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string, ki
// 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}
@@ -1406,61 +1366,29 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string, ki
}
// Search members.
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"))
}
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.
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"))
}
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"))
}
}
// 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, "; "))
// 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])
}
for _, bucket := range [][]assigneeMatch{idMatches, exactMatches, substringMatches} {
@@ -1473,7 +1401,7 @@ func resolveAssignee(ctx context.Context, client *cli.APIClient, name string, ki
return "", "", ambiguousAssigneeError(input, bucket)
}
}
return "", "", fmt.Errorf("no %s found matching %q", kinds.describe(), input)
return "", "", fmt.Errorf("no member or agent found matching %q", input)
}
func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
@@ -1485,13 +1413,12 @@ 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, 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) {
// 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) {
if client.WorkspaceID == "" {
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
}
@@ -1501,40 +1428,14 @@ func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string,
}
var members []map[string]any
var memberErr error
if kinds.member {
memberErr = client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
}
memberErr := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
var agents []map[string]any
var agentErr error
if kinds.agent {
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
agentErr = client.GetJSON(ctx, agentPath, &agents)
}
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
agentErr := client.GetJSON(ctx, agentPath, &agents)
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)
if memberErr != nil && agentErr != nil {
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", memberErr, agentErr)
}
for _, m := range members {
@@ -1547,29 +1448,23 @@ 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 %s found with ID %q", kinds.describe(), input)
return "", "", fmt.Errorf("no member or agent found with ID %q", input)
}
// pickAssigneeFromFlags reads a (name-flag, id-flag) pair off cmd and resolves
// 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.
// 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.
//
// 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, kinds assigneeKinds) (string, string, bool, error) {
func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobra.Command, nameFlag, idFlag string) (string, string, bool, error) {
nameSet := cmd.Flags().Changed(nameFlag)
idSet := cmd.Flags().Changed(idFlag)
if nameSet && idSet {
@@ -1577,7 +1472,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, kinds)
t, i, err := resolveAssigneeByID(ctx, client, idVal)
if err != nil {
return "", "", true, err
}
@@ -1585,7 +1480,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, kinds)
t, i, err := resolveAssignee(ctx, client, name)
if err != nil {
return "", "", true, err
}

View File

@@ -207,10 +207,8 @@ 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 {
@@ -223,7 +221,6 @@ 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 {
@@ -575,9 +572,6 @@ 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 {
@@ -585,8 +579,6 @@ 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)
}
@@ -597,7 +589,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", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -607,7 +599,7 @@ func TestResolveAssignee(t *testing.T) {
})
t.Run("case-insensitive substring", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "bob", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "bob")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -617,7 +609,7 @@ func TestResolveAssignee(t *testing.T) {
})
t.Run("match agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "codebot", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "codebot")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -626,31 +618,8 @@ 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", issueAssigneeKinds)
_, _, err := resolveAssignee(ctx, client, "nobody")
if err == nil {
t.Fatal("expected error for no match")
}
@@ -659,7 +628,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", issueAssigneeKinds)
_, _, err := resolveAssignee(ctx, client, "o")
if err == nil {
t.Fatal("expected error for ambiguous match")
}
@@ -670,92 +639,13 @@ 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", issueAssigneeKinds)
_, _, err := resolveAssignee(ctx, noWSClient, "alice")
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
@@ -771,8 +661,6 @@ 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)
}
@@ -783,7 +671,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", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "reviewer")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -793,7 +681,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", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -803,7 +691,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 ", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -815,7 +703,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", issueAssigneeKinds)
_, _, err := resolveAssignee(ctx, client, "review")
if err == nil {
t.Fatal("expected error for ambiguous substring match")
}
@@ -836,17 +724,12 @@ 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)
}
@@ -857,7 +740,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", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "f656eab8-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -867,7 +750,7 @@ func TestResolveAssigneeByID(t *testing.T) {
})
t.Run("8-char ShortID resolves agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "f656eab8", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "f656eab8")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -877,7 +760,7 @@ func TestResolveAssigneeByID(t *testing.T) {
})
t.Run("uppercase ShortID still resolves", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -887,7 +770,7 @@ func TestResolveAssigneeByID(t *testing.T) {
})
t.Run("ShortID resolves a member", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa", issueAssigneeKinds)
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -909,17 +792,12 @@ 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)
}
@@ -933,7 +811,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", issueAssigneeKinds)
aType, aID, err := resolveAssigneeByID(ctx, client, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -943,7 +821,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", issueAssigneeKinds)
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -953,7 +831,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", issueAssigneeKinds)
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -962,21 +840,8 @@ 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", issueAssigneeKinds)
_, _, err := resolveAssigneeByID(ctx, client, "Alice")
if err == nil {
t.Fatal("expected error for non-UUID input")
}
@@ -986,25 +851,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", issueAssigneeKinds)
_, _, err := resolveAssigneeByID(ctx, client, "5fb87ac7")
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", issueAssigneeKinds)
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111")
if err == nil {
t.Fatal("expected error for missing entity")
}
if !strings.Contains(err.Error(), "no member, agent, or squad") {
if !strings.Contains(err.Error(), "no member or agent") {
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", issueAssigneeKinds)
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d")
if err == nil {
t.Fatal("expected error for missing workspace ID")
}
@@ -1028,8 +893,6 @@ 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)
}
@@ -1047,7 +910,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", issueAssigneeKinds)
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
@@ -1059,7 +922,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", issueAssigneeKinds)
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
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)
}
@@ -1068,7 +931,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", issueAssigneeKinds)
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
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)
}
@@ -1078,7 +941,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", issueAssigneeKinds)
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
if err == nil {
t.Fatal("expected mutually-exclusive error")
}
@@ -1096,7 +959,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", issueAssigneeKinds)
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
if err == nil {
t.Fatal("expected UUID error for explicit empty assignee-id")
}
@@ -1111,7 +974,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", issueAssigneeKinds)
_, _, has, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
if err == nil {
t.Fatal("expected resolver error for explicit empty assignee")
}
@@ -1124,106 +987,13 @@ func TestPickAssigneeFromFlags(t *testing.T) {
c := newCmd()
_ = c.Flags().Set("assignee", "")
_ = c.Flags().Set("assignee-id", "")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id", issueAssigneeKinds)
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "assignee", "assignee-id")
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{
{
@@ -1324,9 +1094,6 @@ 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 {
@@ -1342,7 +1109,7 @@ func TestIssueSubscriberMutationBody(t *testing.T) {
body := map[string]any{}
if tt.user != "" {
uType, uID, err := resolveAssignee(ctx, client, tt.user, issueAssigneeKinds)
uType, uID, err := resolveAssignee(ctx, client, tt.user)
if err != nil {
t.Fatalf("resolveAssignee: %v", err)
}

View File

@@ -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, memberOrAgentKinds)
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
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, memberOrAgentKinds)
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
if resolveErr != nil {
return fmt.Errorf("resolve lead: %w", resolveErr)
}

View File

@@ -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` / `multica squad 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`.\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,7 +120,6 @@ 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")
@@ -135,7 +134,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, 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 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 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")

View File

@@ -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`, `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")
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")
agentID := ""
agentName := ""
if task.Agent != nil {

View File

@@ -41,24 +41,6 @@ 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