mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 00:49:22 +02:00
Compare commits
3 Commits
agent/j/30
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c61a0e888 | ||
|
|
bc25de8b3a | ||
|
|
285c3fb3ca |
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user