mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-02 20:11:09 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7cadb49d8 | ||
|
|
bd00736c37 | ||
|
|
7973fdb4c0 |
@@ -305,10 +305,11 @@ multica workspace members <workspace-id>
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -321,9 +322,10 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -335,9 +337,12 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -32,9 +32,10 @@ The command-line equivalent:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
|
||||
Unassign:
|
||||
|
||||
|
||||
@@ -32,9 +32,10 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配。
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -221,10 +221,11 @@ multica workspace members <workspace-id>
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -237,9 +238,10 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -251,9 +253,12 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -208,7 +208,8 @@ func init() {
|
||||
issueListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
issueListCmd.Flags().String("status", "", "Filter by status")
|
||||
issueListCmd.Flags().String("priority", "", "Filter by priority")
|
||||
issueListCmd.Flags().String("assignee", "", "Filter by assignee name")
|
||||
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)")
|
||||
@@ -222,7 +223,8 @@ func init() {
|
||||
issueCreateCmd.Flags().Bool("description-stdin", false, "Read issue description from stdin (preserves multi-line content verbatim)")
|
||||
issueCreateCmd.Flags().String("status", "", "Issue status")
|
||||
issueCreateCmd.Flags().String("priority", "", "Issue priority")
|
||||
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent)")
|
||||
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)")
|
||||
@@ -235,7 +237,8 @@ func init() {
|
||||
issueUpdateCmd.Flags().Bool("description-stdin", false, "Read new description from stdin (preserves multi-line content verbatim)")
|
||||
issueUpdateCmd.Flags().String("status", "", "New status")
|
||||
issueUpdateCmd.Flags().String("priority", "", "New priority")
|
||||
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)")
|
||||
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)")
|
||||
@@ -245,7 +248,8 @@ func init() {
|
||||
issueStatusCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// issue assign
|
||||
issueAssignCmd.Flags().String("to", "", "Assignee name (member or agent)")
|
||||
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")
|
||||
|
||||
@@ -281,11 +285,13 @@ func init() {
|
||||
issueSubscriberListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// issue subscriber add
|
||||
issueSubscriberAddCmd.Flags().String("user", "", "Member or agent name to subscribe (defaults to the caller)")
|
||||
issueSubscriberAddCmd.Flags().String("user", "", "Member or agent name to subscribe (fuzzy match; defaults to the caller)")
|
||||
issueSubscriberAddCmd.Flags().String("user-id", "", "Member or agent UUID to subscribe (mutually exclusive with --user)")
|
||||
issueSubscriberAddCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// issue subscriber remove
|
||||
issueSubscriberRemoveCmd.Flags().String("user", "", "Member or agent name to unsubscribe (defaults to the caller)")
|
||||
issueSubscriberRemoveCmd.Flags().String("user", "", "Member or agent name to unsubscribe (fuzzy match; defaults to the caller)")
|
||||
issueSubscriberRemoveCmd.Flags().String("user-id", "", "Member or agent UUID to unsubscribe (mutually exclusive with --user)")
|
||||
issueSubscriberRemoveCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
@@ -319,11 +325,11 @@ func runIssueList(cmd *cobra.Command, _ []string) error {
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
params.Set("limit", fmt.Sprintf("%d", v))
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("assignee"); v != "" {
|
||||
_, aID, resolveErr := resolveAssignee(ctx, client, v)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
_, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
if hasAssignee {
|
||||
params.Set("assignee_id", aID)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
|
||||
@@ -476,11 +482,11 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||
if v, _ := cmd.Flags().GetString("due-date"); v != "" {
|
||||
body["due_date"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("assignee"); v != "" {
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
if hasAssignee {
|
||||
body["assignee_type"] = aType
|
||||
body["assignee_id"] = aID
|
||||
}
|
||||
@@ -599,14 +605,15 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
|
||||
v, _ := cmd.Flags().GetString("due-date")
|
||||
body["due_date"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("assignee") {
|
||||
v, _ := cmd.Flags().GetString("assignee")
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, v)
|
||||
if cmd.Flags().Changed("assignee") || cmd.Flags().Changed("assignee-id") {
|
||||
aType, aID, hasAssignee, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "assignee", "assignee-id")
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
body["assignee_type"] = aType
|
||||
body["assignee_id"] = aID
|
||||
if hasAssignee {
|
||||
body["assignee_type"] = aType
|
||||
body["assignee_id"] = aID
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("parent") {
|
||||
v, _ := cmd.Flags().GetString("parent")
|
||||
@@ -645,12 +652,14 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
|
||||
func runIssueAssign(cmd *cobra.Command, args []string) error {
|
||||
toName, _ := cmd.Flags().GetString("to")
|
||||
unassign, _ := cmd.Flags().GetBool("unassign")
|
||||
toNameSet := cmd.Flags().Changed("to")
|
||||
toIDSet := cmd.Flags().Changed("to-id")
|
||||
|
||||
if toName == "" && !unassign {
|
||||
return fmt.Errorf("provide --to <name> or --unassign")
|
||||
if !toNameSet && !toIDSet && !unassign {
|
||||
return fmt.Errorf("provide --to <name>, --to-id <uuid>, or --unassign")
|
||||
}
|
||||
if toName != "" && unassign {
|
||||
return fmt.Errorf("--to and --unassign are mutually exclusive")
|
||||
if (toNameSet || toIDSet) && unassign {
|
||||
return fmt.Errorf("--to/--to-id and --unassign are mutually exclusive")
|
||||
}
|
||||
|
||||
client, err := newAPIClient(cmd)
|
||||
@@ -662,16 +671,20 @@ func runIssueAssign(cmd *cobra.Command, args []string) error {
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{}
|
||||
displayTarget := toName
|
||||
if unassign {
|
||||
body["assignee_type"] = nil
|
||||
body["assignee_id"] = nil
|
||||
} else {
|
||||
aType, aID, resolveErr := resolveAssignee(ctx, client, toName)
|
||||
aType, aID, _, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "to", "to-id")
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve assignee: %w", resolveErr)
|
||||
}
|
||||
body["assignee_type"] = aType
|
||||
body["assignee_id"] = aID
|
||||
if displayTarget == "" {
|
||||
displayTarget = truncateID(aID)
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
@@ -682,7 +695,7 @@ func runIssueAssign(cmd *cobra.Command, args []string) error {
|
||||
if unassign {
|
||||
fmt.Fprintf(os.Stderr, "Issue %s unassigned.\n", truncateID(args[0]))
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Issue %s assigned to %s.\n", truncateID(args[0]), toName)
|
||||
fmt.Fprintf(os.Stderr, "Issue %s assigned to %s.\n", truncateID(args[0]), displayTarget)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
@@ -1145,11 +1158,11 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
|
||||
|
||||
body := map[string]any{}
|
||||
userName, _ := cmd.Flags().GetString("user")
|
||||
if userName != "" {
|
||||
uType, uID, resolveErr := resolveAssignee(ctx, client, userName)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve user: %w", resolveErr)
|
||||
}
|
||||
uType, uID, hasUser, resolveErr := pickAssigneeFromFlags(ctx, client, cmd, "user", "user-id")
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("resolve user: %w", resolveErr)
|
||||
}
|
||||
if hasUser {
|
||||
body["user_type"] = uType
|
||||
body["user_id"] = uID
|
||||
}
|
||||
@@ -1163,6 +1176,8 @@ func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) erro
|
||||
target := "caller"
|
||||
if userName != "" {
|
||||
target = userName
|
||||
} else if hasUser {
|
||||
target = truncateID(uID)
|
||||
}
|
||||
if action == "subscribe" {
|
||||
fmt.Fprintf(os.Stderr, "Subscribed %s to issue %s.\n", target, truncateID(issueID))
|
||||
@@ -1269,6 +1284,83 @@ func ambiguousAssigneeError(input string, matches []assigneeMatch) error {
|
||||
return fmt.Errorf("ambiguous assignee %q; matches:\n%s", input, strings.Join(parts, "\n"))
|
||||
}
|
||||
|
||||
// resolveAssigneeByID strictly resolves a canonical UUID to (assignee_type,
|
||||
// assignee_id) by looking it up against the workspace's members and agents.
|
||||
// It is the deterministic counterpart to resolveAssignee: callers that already
|
||||
// hold a UUID (e.g. agents reading IDs from `multica workspace members
|
||||
// --output json`) should use this instead of round-tripping through name
|
||||
// matching, which can be ambiguous in workspaces with overlapping names.
|
||||
func resolveAssigneeByID(ctx context.Context, client *cli.APIClient, id string) (string, string, error) {
|
||||
if client.WorkspaceID == "" {
|
||||
return "", "", fmt.Errorf("workspace ID is required to resolve assignees; use --workspace-id or set MULTICA_WORKSPACE_ID")
|
||||
}
|
||||
input := strings.TrimSpace(id)
|
||||
if !uuidRegexp.MatchString(input) {
|
||||
return "", "", fmt.Errorf("expected a canonical UUID, got %q", id)
|
||||
}
|
||||
|
||||
var members []map[string]any
|
||||
memberErr := client.GetJSON(ctx, "/api/workspaces/"+client.WorkspaceID+"/members", &members)
|
||||
|
||||
var agents []map[string]any
|
||||
agentPath := "/api/agents?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
agentErr := client.GetJSON(ctx, agentPath, &agents)
|
||||
|
||||
if memberErr != nil && agentErr != nil {
|
||||
return "", "", fmt.Errorf("failed to resolve assignee: %v; %v", memberErr, agentErr)
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
if strings.EqualFold(strVal(m, "user_id"), input) {
|
||||
return "member", strVal(m, "user_id"), nil
|
||||
}
|
||||
}
|
||||
for _, a := range agents {
|
||||
if strings.EqualFold(strVal(a, "id"), input) {
|
||||
return "agent", strVal(a, "id"), nil
|
||||
}
|
||||
}
|
||||
|
||||
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). The third return reports whether either
|
||||
// flag was *explicitly set*; callers use it to decide whether to write
|
||||
// `assignee_*` into the request body. The two flags are mutually exclusive —
|
||||
// passing both is rejected up-front so a script that accidentally sets both
|
||||
// never silently applies one over the other.
|
||||
//
|
||||
// Presence is detected via Flags().Changed (not value-emptiness): a script
|
||||
// that interpolates an empty env var (`--assignee-id "$MAYBE_UUID"`) must
|
||||
// fail loudly through resolveAssignee/resolveAssigneeByID rather than silently
|
||||
// degrade to "no filter / unassigned / subscribe caller", which would defeat
|
||||
// the strict-UUID guarantee the new flags exist for.
|
||||
func pickAssigneeFromFlags(ctx context.Context, client *cli.APIClient, cmd *cobra.Command, nameFlag, idFlag string) (string, string, bool, error) {
|
||||
nameSet := cmd.Flags().Changed(nameFlag)
|
||||
idSet := cmd.Flags().Changed(idFlag)
|
||||
if nameSet && idSet {
|
||||
return "", "", false, fmt.Errorf("--%s and --%s are mutually exclusive", nameFlag, idFlag)
|
||||
}
|
||||
if idSet {
|
||||
idVal, _ := cmd.Flags().GetString(idFlag)
|
||||
t, i, err := resolveAssigneeByID(ctx, client, idVal)
|
||||
if err != nil {
|
||||
return "", "", true, err
|
||||
}
|
||||
return t, i, true, nil
|
||||
}
|
||||
if nameSet {
|
||||
name, _ := cmd.Flags().GetString(nameFlag)
|
||||
t, i, err := resolveAssignee(ctx, client, name)
|
||||
if err != nil {
|
||||
return "", "", true, err
|
||||
}
|
||||
return t, i, true, nil
|
||||
}
|
||||
return "", "", false, nil
|
||||
}
|
||||
|
||||
func formatAssignee(issue map[string]any) string {
|
||||
aType := strVal(issue, "assignee_type")
|
||||
aID := strVal(issue, "assignee_id")
|
||||
|
||||
@@ -358,6 +358,220 @@ func TestResolveAssigneeByID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestResolveAssigneeByIDStrict covers the strict UUID resolver that backs
|
||||
// --assignee-id / --to-id / --user-id. Unlike resolveAssignee it must reject
|
||||
// non-UUID inputs (no name fallback) and surface a clear error when the UUID
|
||||
// is well-formed but not present in the workspace.
|
||||
func TestResolveAssigneeByIDStrict(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"},
|
||||
{"id": "192b9cca-2222-2222-2222-222222222222", "name": "Open Claw - J"},
|
||||
}
|
||||
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)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("full UUID resolves the right agent in a substring-collision workspace", func(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")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "agent" || aID != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
||||
t.Errorf("got (%q, %q), want agent J", aType, aID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercase UUID is normalized", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "5FB87AC7-23B5-4A7A-81FA-ED295A54545D")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "agent" || aID != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
|
||||
t.Errorf("got (%q, %q), want agent J", aType, aID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UUID resolves a member", func(t *testing.T) {
|
||||
aType, aID, err := resolveAssigneeByID(ctx, client, "aaaaaaaa-1111-1111-1111-111111111111")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if aType != "member" || aID != "aaaaaaaa-1111-1111-1111-111111111111" {
|
||||
t.Errorf("got (%q, %q), want Alice", aType, aID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-UUID input is rejected without name fallback", func(t *testing.T) {
|
||||
_, _, err := resolveAssigneeByID(ctx, client, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-UUID input")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UUID") {
|
||||
t.Errorf("expected UUID error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UUID prefix (ShortID) is rejected — strict mode requires canonical form", func(t *testing.T) {
|
||||
_, _, 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")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing entity")
|
||||
}
|
||||
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")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing workspace ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPickAssigneeFromFlags covers the flag-pair picker that backs every
|
||||
// assignee-taking command. The mutual-exclusion guard is the load-bearing
|
||||
// piece — silently preferring one side would let a buggy script set both
|
||||
// flags and assign the wrong entity.
|
||||
func TestPickAssigneeFromFlags(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"},
|
||||
}
|
||||
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)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
|
||||
ctx := context.Background()
|
||||
|
||||
newCmd := func() *cobra.Command {
|
||||
c := &cobra.Command{Use: "test"}
|
||||
c.Flags().String("assignee", "", "")
|
||||
c.Flags().String("assignee-id", "", "")
|
||||
return c
|
||||
}
|
||||
|
||||
t.Run("neither flag set returns hasValue=false", func(t *testing.T) {
|
||||
_, _, has, err := pickAssigneeFromFlags(ctx, client, newCmd(), "assignee", "assignee-id")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Errorf("expected hasValue=false")
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both flags set is rejected", func(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")
|
||||
if err == nil {
|
||||
t.Fatal("expected mutually-exclusive error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected mutually-exclusive error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Explicit-empty regression: a script that interpolates an empty env var
|
||||
// into `--assignee-id "$MAYBE_UUID"` must NOT silently route through the
|
||||
// "no flag set" branch — that would defeat the whole point of the strict
|
||||
// UUID flag (issue list returning everything, create leaving the issue
|
||||
// unassigned, subscriber add subscribing the caller). Detection is via
|
||||
// Flags().Changed, so an explicit empty string surfaces as a UUID error.
|
||||
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")
|
||||
if err == nil {
|
||||
t.Fatal("expected UUID error for explicit empty assignee-id")
|
||||
}
|
||||
if !has {
|
||||
t.Errorf("expected hasValue=true so caller treats this as a real attempt, not a no-op")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UUID") {
|
||||
t.Errorf("expected UUID-shaped error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
if err == nil {
|
||||
t.Fatal("expected resolver error for explicit empty assignee")
|
||||
}
|
||||
if !has {
|
||||
t.Errorf("expected hasValue=true so caller treats this as a real attempt, not a no-op")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit empty on both flags is mutually exclusive (set wins over value)", func(t *testing.T) {
|
||||
c := newCmd()
|
||||
_ = c.Flags().Set("assignee", "")
|
||||
_ = c.Flags().Set("assignee-id", "")
|
||||
_, _, _, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueSubscriberList(t *testing.T) {
|
||||
subscribersResp := []map[string]any{
|
||||
{
|
||||
|
||||
@@ -105,7 +105,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("**Always use `--output json` for all read commands** to get structured data with full IDs.\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] [--limit N] [--offset N] --output json` — List issues in workspace (default limit: 50; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true)\n")
|
||||
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X | --assignee-id <uuid>] [--limit N] [--offset N] --output json` — List issues in workspace (default limit: 50; 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> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\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")
|
||||
@@ -122,14 +122,14 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- `multica autopilot runs <id> [--limit N] --output json` — List execution history for an autopilot\n\n")
|
||||
|
||||
b.WriteString("### Write\n")
|
||||
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--status X] [--assignee X] [--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] [--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 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>` — Assign an issue to a member or agent by name (use `--unassign` to remove 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>]` — Subscribe a member or agent to issue updates (defaults to the caller when `--user` is omitted)\n")
|
||||
b.WriteString("- `multica issue subscriber remove <issue-id> [--user <name>]` — Unsubscribe a member or agent\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")
|
||||
b.WriteString("- `multica issue subscriber remove <issue-id> [--user <name>|--user-id <uuid>]` — Unsubscribe a member or agent\n")
|
||||
b.WriteString("- `multica issue comment add <issue-id> --content-stdin [--parent <comment-id>] [--attachment <path>]` — Post a comment. Agent-authored comments should always pipe content via stdin, even for short single-line replies. Use `--parent` to reply to a specific comment; `--attachment` may be repeated.\n")
|
||||
b.WriteString(" - **For comment content, you MUST pipe via stdin; this is mandatory for multi-line content (anything with line breaks, paragraphs, code blocks, backticks, or quotes).** Do not use inline `--content` and do not write `\\n` escapes. Use a HEREDOC instead:\n")
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -65,15 +65,19 @@ func buildQuickCreatePrompt(task Task) string {
|
||||
|
||||
// assignee
|
||||
b.WriteString("- **assignee**:\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — 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 {
|
||||
agentID = task.Agent.ID
|
||||
agentName = task.Agent.Name
|
||||
}
|
||||
if agentName != "" {
|
||||
if agentID != "" {
|
||||
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee-id %q` (your agent UUID). The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned. Use the UUID flag, not `--assignee <name>`, so the assignment is unambiguous even when other agents share part of your name.\n\n", agentID)
|
||||
} else if agentName != "" {
|
||||
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n\n", agentName)
|
||||
} else {
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee-id <your agent UUID>` (preferred) or `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
|
||||
}
|
||||
|
||||
// fields to omit
|
||||
|
||||
Reference in New Issue
Block a user