mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
* fix(cli): resolve squad assignees in issue create/update/assign (MUL-2165) The CLI assignee resolver only searched workspace members and agents, so a quick-create input like "assign to <SquadName>" silently fell through to "Unrecognized assignee: <SquadName>" in the issue description — even though squads are first-class assignees server-side and the prompt's whole point was to route the work for the user. Extend resolveAssignee / resolveAssigneeByID to also fetch /api/squads, teach the actor display lookup to render squad names in table output, update the quick-create prompt and runtime-config command listing to mention `multica squad list` alongside members and agents, and lock in the new behavior with tests. Co-authored-by: multica-agent <github@multica.ai> * fix(cli): gate squad assignee resolution behind an allowed-kinds set (MUL-2165) The earlier MUL-2165 fix taught resolveAssignee / resolveAssigneeByID to also return (squad, ...), but those helpers are shared. Project lead and issue subscriber callers were still using them, and their target schemas reject squads — project.lead_type has a DB CHECK constraint (server/migrations/034_projects.up.sql:10) and the subscriber handler's isWorkspaceEntity switch only knows member/agent (server/internal/handler/handler.go:414). So `multica project create --lead "<SquadName>"` and `multica issue subscriber add --user "<SquadName>"` would resolve to (squad, ...) and surface as a 500/403 server-side instead of a clean CLI-side resolution error. Thread an assigneeKinds set through the resolver and the pickAssigneeFromFlags helper. Issue create/update/assign/list pass `issueAssigneeKinds` (all three); project lead and subscriber pass `memberOrAgentKinds`. The squads fetch is skipped entirely when not allowed, and the not-found / no-match error wording adapts to the allowed kinds so it never mentions a type the caller cannot use. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
559 lines
15 KiB
Go
559 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/multica-ai/multica/server/internal/cli"
|
|
)
|
|
|
|
const minShortIDPrefixLen = 4
|
|
const resolverListPageLimit = 50
|
|
|
|
type resolvedID struct {
|
|
ID string
|
|
Display string
|
|
}
|
|
|
|
type idCandidate struct {
|
|
ID string
|
|
Display string
|
|
Detail string
|
|
}
|
|
|
|
func displayID(id string, full bool) string {
|
|
if full {
|
|
return id
|
|
}
|
|
return truncateID(id)
|
|
}
|
|
|
|
func issueDisplayKey(issue map[string]any) string {
|
|
if key := strVal(issue, "identifier"); key != "" {
|
|
return key
|
|
}
|
|
id := strVal(issue, "id")
|
|
if id != "" {
|
|
slog.Warn("issue response missing identifier", "issue_id", id)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func issueCandidate(issue map[string]any) idCandidate {
|
|
return idCandidate{
|
|
ID: strVal(issue, "id"),
|
|
Display: issueDisplayKey(issue),
|
|
Detail: strVal(issue, "title"),
|
|
}
|
|
}
|
|
|
|
func normalizeUUIDPrefix(input string) (string, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("id is required")
|
|
}
|
|
prefix := strings.ToLower(strings.ReplaceAll(trimmed, "-", ""))
|
|
if len(prefix) < minShortIDPrefixLen {
|
|
return "", fmt.Errorf("expected a full UUID or at least %d hex characters, got %q", minShortIDPrefixLen, input)
|
|
}
|
|
for _, r := range prefix {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
|
return "", fmt.Errorf("expected a UUID prefix containing only hex characters, got %q", input)
|
|
}
|
|
}
|
|
return prefix, nil
|
|
}
|
|
|
|
func compactUUID(id string) string {
|
|
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(id), "-", ""))
|
|
}
|
|
|
|
func resolveIDByPrefix(ctx context.Context, client *cli.APIClient, kind, input string, fetch func(context.Context, *cli.APIClient) ([]idCandidate, error)) (resolvedID, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return resolvedID{}, fmt.Errorf("%s id is required", kind)
|
|
}
|
|
if uuidRegexp.MatchString(trimmed) {
|
|
return resolvedID{ID: trimmed, Display: trimmed}, nil
|
|
}
|
|
|
|
prefix, err := normalizeUUIDPrefix(trimmed)
|
|
if err != nil {
|
|
return resolvedID{}, fmt.Errorf("resolve %s: %w", kind, err)
|
|
}
|
|
|
|
candidates, err := fetch(ctx, client)
|
|
if err != nil {
|
|
return resolvedID{}, fmt.Errorf("resolve %s: %w", kind, err)
|
|
}
|
|
|
|
matches := make([]idCandidate, 0, 1)
|
|
for _, c := range candidates {
|
|
if c.ID == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(compactUUID(c.ID), prefix) {
|
|
matches = append(matches, c)
|
|
}
|
|
}
|
|
|
|
switch len(matches) {
|
|
case 0:
|
|
return resolvedID{}, fmt.Errorf("no %s found matching id prefix %q; run the list command with --full-id to copy the full UUID", kind, input)
|
|
case 1:
|
|
display := matches[0].Display
|
|
if display == "" {
|
|
display = matches[0].ID
|
|
}
|
|
return resolvedID{ID: matches[0].ID, Display: display}, nil
|
|
default:
|
|
return resolvedID{}, ambiguousIDPrefixError(kind, input, matches)
|
|
}
|
|
}
|
|
|
|
func ambiguousIDPrefixError(kind, input string, matches []idCandidate) error {
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
return matches[i].ID < matches[j].ID
|
|
})
|
|
parts := make([]string, 0, len(matches))
|
|
for _, m := range matches {
|
|
parts = append(parts, " "+m.ID)
|
|
}
|
|
return fmt.Errorf("ambiguous %s id prefix %q; matches:\n%s\nUse more characters or run the list command with --full-id", kind, input, strings.Join(parts, "\n"))
|
|
}
|
|
|
|
func resolveIssueRef(ctx context.Context, client *cli.APIClient, input string) (resolvedID, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if trimmed == "" {
|
|
return resolvedID{}, fmt.Errorf("issue id is required")
|
|
}
|
|
|
|
// Preserve issue-key semantics before considering UUID prefixes. This
|
|
// mirrors the server-side loadIssueForUser order and avoids treating
|
|
// strings like MUL-1852 as a UUID prefix.
|
|
if looksLikeIssueIdentifier(trimmed) {
|
|
return fetchIssueRef(ctx, client, trimmed)
|
|
}
|
|
if uuidRegexp.MatchString(trimmed) {
|
|
return fetchIssueRef(ctx, client, trimmed)
|
|
}
|
|
return resolveIDByPrefix(ctx, client, "issue", trimmed, fetchIssueCandidates)
|
|
}
|
|
|
|
func fetchIssueRef(ctx context.Context, client *cli.APIClient, ref string) (resolvedID, error) {
|
|
var issue map[string]any
|
|
if err := client.GetJSON(ctx, "/api/issues/"+url.PathEscape(ref), &issue); err != nil {
|
|
return resolvedID{}, err
|
|
}
|
|
c := issueCandidate(issue)
|
|
if c.Display == "" {
|
|
c.Display = c.ID
|
|
}
|
|
return resolvedID{ID: c.ID, Display: c.Display}, nil
|
|
}
|
|
|
|
func looksLikeIssueIdentifier(input string) bool {
|
|
if input == "" {
|
|
return false
|
|
}
|
|
dash := strings.LastIndex(input, "-")
|
|
if dash <= 0 || dash >= len(input)-1 {
|
|
return false
|
|
}
|
|
prefix := input[:dash]
|
|
for _, r := range prefix {
|
|
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
|
return false
|
|
}
|
|
}
|
|
_, ok := parsePositiveInt(input[dash+1:])
|
|
return ok
|
|
}
|
|
|
|
func parsePositiveInt(input string) (int, bool) {
|
|
n, err := strconv.Atoi(strings.TrimSpace(input))
|
|
if err != nil || n <= 0 {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
func fetchIssueCandidates(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
if client.WorkspaceID == "" {
|
|
return nil, fmt.Errorf("workspace_id is required to resolve issue id prefixes")
|
|
}
|
|
const limit = resolverListPageLimit
|
|
candidates := []idCandidate{}
|
|
for offset := 0; ; {
|
|
params := url.Values{}
|
|
params.Set("workspace_id", client.WorkspaceID)
|
|
params.Set("include_closed", "true")
|
|
params.Set("limit", strconv.Itoa(limit))
|
|
if offset > 0 {
|
|
params.Set("offset", strconv.Itoa(offset))
|
|
}
|
|
var result map[string]any
|
|
if err := client.GetJSON(ctx, "/api/issues?"+params.Encode(), &result); err != nil {
|
|
return nil, err
|
|
}
|
|
issuesRaw, _ := result["issues"].([]any)
|
|
for _, raw := range issuesRaw {
|
|
issue, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidates = append(candidates, issueCandidate(issue))
|
|
}
|
|
offset += len(issuesRaw)
|
|
total, _ := result["total"].(float64)
|
|
if len(issuesRaw) == 0 || (total > 0 && offset >= int(total)) || (total == 0 && len(issuesRaw) < limit) {
|
|
break
|
|
}
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func resolveAutopilotID(ctx context.Context, client *cli.APIClient, input string) (resolvedID, error) {
|
|
return resolveIDByPrefix(ctx, client, "autopilot", input, fetchAutopilotCandidates)
|
|
}
|
|
|
|
func fetchAutopilotCandidates(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
if client.WorkspaceID == "" {
|
|
return nil, fmt.Errorf("workspace_id is required to resolve autopilot id prefixes")
|
|
}
|
|
const limit = resolverListPageLimit
|
|
candidates := []idCandidate{}
|
|
seen := map[string]struct{}{}
|
|
for offset := 0; ; {
|
|
params := url.Values{}
|
|
params.Set("workspace_id", client.WorkspaceID)
|
|
params.Set("limit", strconv.Itoa(limit))
|
|
if offset > 0 {
|
|
params.Set("offset", strconv.Itoa(offset))
|
|
}
|
|
var resp struct {
|
|
Autopilots []map[string]any `json:"autopilots"`
|
|
Total int `json:"total"`
|
|
HasMore bool `json:"has_more"`
|
|
}
|
|
if err := client.GetJSON(ctx, "/api/autopilots?"+params.Encode(), &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
added := 0
|
|
for _, a := range resp.Autopilots {
|
|
id := strVal(a, "id")
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
added++
|
|
candidates = append(candidates, idCandidate{
|
|
ID: id,
|
|
Display: strVal(a, "title"),
|
|
Detail: strVal(a, "status"),
|
|
})
|
|
}
|
|
pageLen := len(resp.Autopilots)
|
|
offset += pageLen
|
|
if pageLen == 0 || added == 0 {
|
|
break
|
|
}
|
|
if pageLen < limit {
|
|
break
|
|
}
|
|
if resp.HasMore {
|
|
continue
|
|
}
|
|
if resp.Total > 0 {
|
|
if offset >= resp.Total {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func resolveTaskRunID(ctx context.Context, client *cli.APIClient, issueID, input string) (resolvedID, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if uuidRegexp.MatchString(trimmed) {
|
|
return resolvedID{ID: trimmed, Display: trimmed}, nil
|
|
}
|
|
if strings.TrimSpace(issueID) == "" {
|
|
return resolvedID{}, fmt.Errorf("short task run prefixes require --issue <issue-id>; pass a full task UUID or run `multica issue runs <issue-id> --full-id`")
|
|
}
|
|
fetch := func(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
return fetchTaskRunCandidatesForIssue(ctx, client, issueID)
|
|
}
|
|
return resolveIDByPrefix(ctx, client, "task run", input, fetch)
|
|
}
|
|
|
|
func fetchTaskRunCandidatesForIssue(ctx context.Context, client *cli.APIClient, issueID string) ([]idCandidate, error) {
|
|
var runs []map[string]any
|
|
if err := client.GetJSON(ctx, "/api/issues/"+url.PathEscape(issueID)+"/task-runs", &runs); err != nil {
|
|
return nil, err
|
|
}
|
|
candidates := make([]idCandidate, 0, len(runs))
|
|
for _, r := range runs {
|
|
id := strVal(r, "id")
|
|
if id == "" {
|
|
continue
|
|
}
|
|
candidates = append(candidates, idCandidate{
|
|
ID: id,
|
|
Display: id,
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func resolveAutopilotTriggerID(ctx context.Context, client *cli.APIClient, autopilotID, input string) (resolvedID, error) {
|
|
trimmed := strings.TrimSpace(input)
|
|
if uuidRegexp.MatchString(trimmed) {
|
|
return resolvedID{ID: trimmed, Display: trimmed}, nil
|
|
}
|
|
fetch := func(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
var resp map[string]any
|
|
if err := client.GetJSON(ctx, "/api/autopilots/"+url.PathEscape(autopilotID), &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
triggersRaw, _ := resp["triggers"].([]any)
|
|
candidates := make([]idCandidate, 0, len(triggersRaw))
|
|
for _, raw := range triggersRaw {
|
|
t, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
detail := strVal(t, "kind")
|
|
if label := strVal(t, "label"); label != "" {
|
|
detail = label
|
|
}
|
|
candidates = append(candidates, idCandidate{
|
|
ID: strVal(t, "id"),
|
|
Display: strVal(t, "id"),
|
|
Detail: detail,
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
return resolveIDByPrefix(ctx, client, "autopilot trigger", input, fetch)
|
|
}
|
|
|
|
func resolveProjectID(ctx context.Context, client *cli.APIClient, input string) (resolvedID, error) {
|
|
return resolveIDByPrefix(ctx, client, "project", input, fetchProjectCandidates)
|
|
}
|
|
|
|
func fetchProjectCandidates(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
if client.WorkspaceID == "" {
|
|
return nil, fmt.Errorf("workspace_id is required to resolve project id prefixes")
|
|
}
|
|
params := url.Values{"workspace_id": {client.WorkspaceID}}
|
|
var result map[string]any
|
|
if err := client.GetJSON(ctx, "/api/projects?"+params.Encode(), &result); err != nil {
|
|
return nil, err
|
|
}
|
|
projectsRaw, _ := result["projects"].([]any)
|
|
candidates := make([]idCandidate, 0, len(projectsRaw))
|
|
for _, raw := range projectsRaw {
|
|
p, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidates = append(candidates, idCandidate{
|
|
ID: strVal(p, "id"),
|
|
Display: strVal(p, "title"),
|
|
Detail: strVal(p, "status"),
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func resolveProjectResourceID(ctx context.Context, client *cli.APIClient, projectID, input string) (resolvedID, error) {
|
|
fetch := func(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
var result map[string]any
|
|
if err := client.GetJSON(ctx, "/api/projects/"+url.PathEscape(projectID)+"/resources", &result); err != nil {
|
|
return nil, err
|
|
}
|
|
resourcesRaw, _ := result["resources"].([]any)
|
|
candidates := make([]idCandidate, 0, len(resourcesRaw))
|
|
for _, raw := range resourcesRaw {
|
|
r, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
display := strVal(r, "label")
|
|
if display == "" {
|
|
display = strVal(r, "resource_type")
|
|
}
|
|
candidates = append(candidates, idCandidate{
|
|
ID: strVal(r, "id"),
|
|
Display: display,
|
|
Detail: summarizeResourceRef(r["resource_ref"]),
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
return resolveIDByPrefix(ctx, client, "project resource", input, fetch)
|
|
}
|
|
|
|
func resolveLabelID(ctx context.Context, client *cli.APIClient, input string) (resolvedID, error) {
|
|
return resolveIDByPrefix(ctx, client, "label", input, fetchLabelCandidates)
|
|
}
|
|
|
|
func fetchLabelCandidates(ctx context.Context, client *cli.APIClient) ([]idCandidate, error) {
|
|
if client.WorkspaceID == "" {
|
|
return nil, fmt.Errorf("workspace_id is required to resolve label id prefixes")
|
|
}
|
|
params := url.Values{"workspace_id": {client.WorkspaceID}}
|
|
var result map[string]any
|
|
if err := client.GetJSON(ctx, "/api/labels?"+params.Encode(), &result); err != nil {
|
|
return nil, err
|
|
}
|
|
labelsRaw, _ := result["labels"].([]any)
|
|
candidates := make([]idCandidate, 0, len(labelsRaw))
|
|
for _, raw := range labelsRaw {
|
|
l, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidates = append(candidates, idCandidate{
|
|
ID: strVal(l, "id"),
|
|
Display: strVal(l, "name"),
|
|
Detail: strVal(l, "color"),
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
type actorDisplayLookup struct {
|
|
ctx context.Context
|
|
client *cli.APIClient
|
|
state *actorDisplayLookupState
|
|
}
|
|
|
|
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 {
|
|
return actorDisplayLookup{
|
|
ctx: ctx,
|
|
client: client,
|
|
state: &actorDisplayLookupState{},
|
|
}
|
|
}
|
|
|
|
func (l actorDisplayLookup) loadMembers() {
|
|
if l.state == nil || l.state.membersLoaded {
|
|
return
|
|
}
|
|
l.state.membersLoaded = true
|
|
l.state.members = map[string]string{}
|
|
if l.client == nil || l.client.WorkspaceID == "" {
|
|
return
|
|
}
|
|
var members []map[string]any
|
|
if err := l.client.GetJSON(l.ctx, "/api/workspaces/"+url.PathEscape(l.client.WorkspaceID)+"/members", &members); err == nil {
|
|
for _, m := range members {
|
|
if id := strVal(m, "user_id"); id != "" {
|
|
l.state.members[id] = strVal(m, "name")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (l actorDisplayLookup) loadAgents() {
|
|
if l.state == nil || l.state.agentsLoaded {
|
|
return
|
|
}
|
|
l.state.agentsLoaded = true
|
|
l.state.agents = map[string]string{}
|
|
if l.client == nil || l.client.WorkspaceID == "" {
|
|
return
|
|
}
|
|
var agents []map[string]any
|
|
agentPath := "/api/agents?" + url.Values{"workspace_id": {l.client.WorkspaceID}}.Encode()
|
|
if err := l.client.GetJSON(l.ctx, agentPath, &agents); err == nil {
|
|
for _, a := range agents {
|
|
if id := strVal(a, "id"); id != "" {
|
|
l.state.agents[id] = strVal(a, "name")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
switch actorType {
|
|
case "member":
|
|
l.loadMembers()
|
|
if l.state != nil && l.state.members != nil {
|
|
if name := l.state.members[id]; name != "" {
|
|
return "member:" + name
|
|
}
|
|
}
|
|
case "agent":
|
|
l.loadAgents()
|
|
if l.state != nil && l.state.agents != nil {
|
|
if name := l.state.agents[id]; name != "" {
|
|
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
|
|
}
|
|
|
|
func (l actorDisplayLookup) agent(id string) string {
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
l.loadAgents()
|
|
if l.state != nil && l.state.agents != nil {
|
|
if name := l.state.agents[id]; name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return id
|
|
}
|