Files
multica/server/cmd/multica/cmd_issue_test.go
Bohan Jiang 241a3582cf fix: validate issue status and priority (#4156)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 12:26:44 +08:00

2207 lines
73 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
// stderrCapture redirects os.Stderr through a pipe so a test can assert on
// the human-facing strings runIssueCommentList prints alongside its JSON
// output. Read() drains the pipe and is safe to call multiple times.
type stderrCapture struct {
t *testing.T
orig *os.File
r, w *os.File
out strings.Builder
doneCh chan struct{}
stopped bool
}
func captureStderr(t *testing.T) *stderrCapture {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
c := &stderrCapture{t: t, orig: os.Stderr, r: r, w: w, doneCh: make(chan struct{})}
os.Stderr = w
go func() {
buf, _ := io.ReadAll(r)
c.out.Write(buf)
close(c.doneCh)
}()
return c
}
func (c *stderrCapture) restore() {
if c.stopped {
return
}
c.stopped = true
os.Stderr = c.orig
_ = c.w.Close()
<-c.doneCh
_ = c.r.Close()
}
func (c *stderrCapture) read() string {
c.restore()
return c.out.String()
}
// pipeStdin replaces os.Stdin with a pipe seeded by the given body for the
// duration of fn, so resolveTextFlag's --content-stdin / --description-stdin
// branch can be exercised in unit tests without spawning a subprocess.
func pipeStdin(t *testing.T, body string, fn func()) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
if _, err := w.WriteString(body); err != nil {
t.Fatalf("write pipe: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close pipe writer: %v", err)
}
orig := os.Stdin
os.Stdin = r
defer func() {
os.Stdin = orig
_ = r.Close()
}()
fn()
}
// newFlagTestCmd builds a throwaway cobra.Command carrying the inline +
// stdin + file flag triplet that resolveTextFlag expects.
func newFlagTestCmd(name string) *cobra.Command {
c := &cobra.Command{Use: "test"}
c.Flags().String(name, "", "")
c.Flags().Bool(name+"-stdin", false, "")
c.Flags().String(name+"-file", "", "")
return c
}
func TestResolveTextFlag(t *testing.T) {
t.Run("inline value is unescaped", func(t *testing.T) {
c := newFlagTestCmd("description")
_ = c.Flags().Set("description", `para1\n\npara2`)
got, ok, err := resolveTextFlag(c, "description")
if err != nil || !ok {
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
}
if got != "para1\n\npara2" {
t.Errorf("got %q, want decoded paragraphs", got)
}
})
t.Run("stdin body is preserved verbatim", func(t *testing.T) {
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-stdin", "true")
body := "first line\nsecond line with a literal \\n in it\n"
pipeStdin(t, body, func() {
got, ok, err := resolveTextFlag(c, "description")
if err != nil || !ok {
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
}
// strings.TrimSuffix one trailing newline like content-stdin.
want := "first line\nsecond line with a literal \\n in it"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
})
t.Run("inline + stdin is rejected", func(t *testing.T) {
c := newFlagTestCmd("description")
_ = c.Flags().Set("description", "inline")
_ = c.Flags().Set("description-stdin", "true")
if _, _, err := resolveTextFlag(c, "description"); err == nil {
t.Fatalf("expected mutually-exclusive error")
}
})
t.Run("missing both returns hasValue=false", func(t *testing.T) {
c := newFlagTestCmd("description")
got, ok, err := resolveTextFlag(c, "description")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if ok || got != "" {
t.Errorf("expected absent flag to yield (\"\", false), got (%q, %v)", got, ok)
}
})
// --content-file / --description-file exists for Windows agents — piping
// HEREDOC content through Windows PowerShell silently drops non-ASCII
// bytes (PS 5.1's $OutputEncoding defaults to ASCIIEncoding when piping
// to a native command), so Chinese / Cyrillic / etc. arrive as `?`.
// Reading the body straight off disk skips the shell entirely.
// See issues #2198, #2236, #2376.
t.Run("file body is preserved verbatim with non-ASCII content", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "desc.md"
body := "标题 / Заголовок\n\n中文段落 with `code` and \"quotes\".\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-file", path)
got, ok, err := resolveTextFlag(c, "description")
if err != nil || !ok {
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
}
want := "标题 / Заголовок\n\n中文段落 with `code` and \"quotes\"."
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("file path that doesn't exist surfaces a useful error", func(t *testing.T) {
c := newFlagTestCmd("content")
_ = c.Flags().Set("content-file", "/this/path/does/not/exist.txt")
_, _, err := resolveTextFlag(c, "content")
if err == nil {
t.Fatalf("expected error for missing file")
}
if !strings.Contains(err.Error(), "content-file") {
t.Errorf("error should mention --content-file, got %v", err)
}
})
t.Run("empty file is rejected", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "empty.md"
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-file", path)
_, _, err := resolveTextFlag(c, "description")
if err == nil {
t.Fatalf("expected error for empty file")
}
})
t.Run("file plus inline is rejected", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "x.md"
if err := os.WriteFile(path, []byte("body"), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description", "inline")
_ = c.Flags().Set("description-file", path)
if _, _, err := resolveTextFlag(c, "description"); err == nil {
t.Fatalf("expected mutually-exclusive error for inline + file")
}
})
t.Run("file plus stdin is rejected", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "x.md"
if err := os.WriteFile(path, []byte("body"), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-stdin", "true")
_ = c.Flags().Set("description-file", path)
if _, _, err := resolveTextFlag(c, "description"); err == nil {
t.Fatalf("expected mutually-exclusive error for stdin + file")
}
})
}
func newIssueCreateTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "create"}
cmd.Flags().String("title", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().Bool("description-stdin", false, "")
cmd.Flags().String("description-file", "", "")
cmd.Flags().String("status", "", "")
cmd.Flags().String("priority", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("assignee-id", "", "")
cmd.Flags().String("parent", "", "")
cmd.Flags().String("project", "", "")
cmd.Flags().String("due-date", "", "")
cmd.Flags().Bool("allow-duplicate", false, "")
cmd.Flags().String("output", "json", "")
cmd.Flags().StringSlice("attachment", nil, "")
cmd.Flags().StringSlice("attachment-id", nil, "")
return cmd
}
func TestRunIssueCreateSendsAllowDuplicate(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/issues" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode body: %v", err)
}
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
"title": "Duplicate allowed",
"status": "todo",
"priority": "none",
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newIssueCreateTestCmd()
_ = cmd.Flags().Set("title", "Duplicate allowed")
_ = cmd.Flags().Set("allow-duplicate", "true")
if err := runIssueCreate(cmd, nil); err != nil {
t.Fatalf("runIssueCreate: %v", err)
}
if got := body["allow_duplicate"]; got != true {
t.Fatalf("allow_duplicate = %#v, want true in request body", got)
}
}
func TestRunIssueCreateSendsExistingAttachmentIDs(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/issues" {
http.NotFound(w, r)
return
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode body: %v", err)
}
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
"title": "With attachments",
"status": "todo",
"priority": "none",
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
t.Setenv("MULTICA_QUICK_CREATE_ATTACHMENT_IDS", `["att-env","att-shared"]`)
cmd := newIssueCreateTestCmd()
_ = cmd.Flags().Set("title", "With attachments")
_ = cmd.Flags().Set("attachment-id", "att-flag")
_ = cmd.Flags().Set("attachment-id", "att-shared")
if err := runIssueCreate(cmd, nil); err != nil {
t.Fatalf("runIssueCreate: %v", err)
}
got, ok := body["attachment_ids"].([]any)
if !ok {
t.Fatalf("attachment_ids = %#v, want JSON array", body["attachment_ids"])
}
want := []string{"att-flag", "att-shared", "att-env"}
if len(got) != len(want) {
t.Fatalf("attachment_ids length = %d, want %d (%#v)", len(got), len(want), got)
}
for i, w := range want {
if got[i] != w {
t.Fatalf("attachment_ids[%d] = %#v, want %q (all=%#v)", i, got[i], w, got)
}
}
}
func TestRunIssueCreateShowsDuplicateMessage(t *testing.T) {
want := "Active duplicate issue exists: YUA-36 SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs (status: in_progress). Set allow_duplicate=true or use --allow-duplicate to create another."
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/issues" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
"code": "active_duplicate_issue",
"error": want,
"issue": map[string]any{
"id": "issue-id",
"identifier": "YUA-36",
"status": "in_progress",
},
})
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newIssueCreateTestCmd()
_ = cmd.Flags().Set("title", "SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs")
err := runIssueCreate(cmd, nil)
if err == nil {
t.Fatal("runIssueCreate: expected duplicate error")
}
if got := err.Error(); got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
func newIssuePullRequestsTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "pull-requests"}
cmd.Flags().String("output", "table", "")
return cmd
}
func TestRunIssuePullRequestsListsLinkedPRsAsJSON(t *testing.T) {
var gotPaths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPaths = append(gotPaths, r.URL.Path)
switch r.URL.Path {
case "/api/issues/MUL-2818":
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-uuid",
"identifier": "MUL-2818",
"title": "CLI PR lookup",
})
case "/api/issues/issue-uuid/pull-requests":
json.NewEncoder(w).Encode(map[string]any{
"pull_requests": []map[string]any{
{
"url": "https://github.com/multica-ai/multica/pull/42",
"number": float64(42),
"state": "open",
"title": "MUL-2818 add issue PR CLI",
},
},
})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newIssuePullRequestsTestCmd()
_ = cmd.Flags().Set("output", "json")
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := runIssuePullRequests(cmd, []string{"MUL-2818"})
_ = w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
if err != nil {
t.Fatalf("runIssuePullRequests: %v", err)
}
if want := []string{"/api/issues/MUL-2818", "/api/issues/issue-uuid/pull-requests"}; fmt.Sprint(gotPaths) != fmt.Sprint(want) {
t.Fatalf("paths = %v, want %v", gotPaths, want)
}
var payload map[string]any
if err := json.Unmarshal(out, &payload); err != nil {
t.Fatalf("decode JSON output: %v\n%s", err, string(out))
}
prs, _ := payload["pull_requests"].([]any)
if len(prs) != 1 {
t.Fatalf("pull_requests length = %d, want 1", len(prs))
}
pr, _ := prs[0].(map[string]any)
if pr["url"] != "https://github.com/multica-ai/multica/pull/42" || pr["number"] != float64(42) || pr["state"] != "open" || pr["title"] != "MUL-2818 add issue PR CLI" {
t.Fatalf("unexpected PR payload: %#v", pr)
}
}
func TestRunIssuePullRequestsTableIncludesCoreFields(t *testing.T) {
prs := []map[string]any{{
"url": "https://github.com/multica-ai/multica/pull/42",
"number": float64(42),
"state": "open",
"title": "MUL-2818 add issue PR CLI",
}}
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
printIssuePullRequestsTable(prs)
_ = w.Close()
os.Stdout = old
out, _ := io.ReadAll(r)
text := string(out)
for _, want := range []string{"NUMBER", "STATE", "TITLE", "URL", "42", "open", "MUL-2818 add issue PR CLI", "https://github.com/multica-ai/multica/pull/42"} {
if !strings.Contains(text, want) {
t.Fatalf("table output missing %q:\n%s", want, text)
}
}
}
func TestTruncateID(t *testing.T) {
tests := []struct {
name string
id string
want string
}{
{"short", "abc", "abc"},
{"exact 8", "abcdefgh", "abcdefgh"},
{"longer than 8", "abcdefgh-1234-5678", "abcdefgh"},
{"empty", "", ""},
{"unicode", "日本語テスト文字列追加", "日本語テスト文字"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateID(tt.id)
if got != tt.want {
t.Errorf("truncateID(%q) = %q, want %q", tt.id, got, tt.want)
}
})
}
}
func TestFormatAssignee(t *testing.T) {
actors := actorDisplayLookup{
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 {
name string
issue map[string]any
want string
}{
{"empty", map[string]any{}, ""},
{"no type", map[string]any{"assignee_id": "abc"}, ""},
{"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 {
t.Run(tt.name, func(t *testing.T) {
got := formatAssignee(tt.issue, actors)
if got != tt.want {
t.Errorf("formatAssignee() = %q, want %q", got, tt.want)
}
})
}
}
func TestActorDisplayLookupLazyLoads(t *testing.T) {
var memberCalls, agentCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/workspaces/ws-1/members":
memberCalls++
json.NewEncoder(w).Encode([]map[string]any{{"user_id": "user-1", "name": "Alice"}})
case "/api/agents":
agentCalls++
json.NewEncoder(w).Encode([]map[string]any{{"id": "agent-1", "name": "CodeBot"}})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
lookup := loadActorDisplayLookup(context.Background(), client)
if got := lookup.agent("agent-1"); got != "CodeBot" {
t.Fatalf("agent() = %q, want CodeBot", got)
}
if memberCalls != 0 || agentCalls != 1 {
t.Fatalf("after agent lookup: memberCalls=%d agentCalls=%d, want 0/1", memberCalls, agentCalls)
}
if got := lookup.actor("member", "user-1"); got != "member:Alice" {
t.Fatalf("actor(member) = %q, want member:Alice", got)
}
if memberCalls != 1 || agentCalls != 1 {
t.Fatalf("after member lookup: memberCalls=%d agentCalls=%d, want 1/1", memberCalls, agentCalls)
}
if got := lookup.actor("member", "missing"); got != "member:missing" {
t.Fatalf("actor(missing member) = %q", got)
}
if memberCalls != 1 || agentCalls != 1 {
t.Fatalf("lookup should cache per type: memberCalls=%d agentCalls=%d", memberCalls, agentCalls)
}
}
func TestResolveIDByPrefix(t *testing.T) {
client := cli.NewAPIClient("http://example.invalid", "ws-1", "test-token")
ctx := context.Background()
fetch := func(context.Context, *cli.APIClient) ([]idCandidate, error) {
return []idCandidate{
{ID: "aaaaaaaa-1111-2222-3333-444444444444", Display: "Alpha"},
{ID: "bbbbbbbb-1111-2222-3333-444444444444", Display: "Beta"},
{ID: "aaaabbbb-1111-2222-3333-444444444444", Display: "Alpha Two"},
}, nil
}
t.Run("full UUID passes through", func(t *testing.T) {
got, err := resolveIDByPrefix(ctx, client, "thing", "bbbbbbbb-1111-2222-3333-444444444444", fetch)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != "bbbbbbbb-1111-2222-3333-444444444444" {
t.Fatalf("got %q", got.ID)
}
})
t.Run("unique short prefix resolves", func(t *testing.T) {
got, err := resolveIDByPrefix(ctx, client, "thing", "bbbb", fetch)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != "bbbbbbbb-1111-2222-3333-444444444444" || got.Display != "Beta" {
t.Fatalf("got %#v", got)
}
})
t.Run("short prefix can include dashes", func(t *testing.T) {
got, err := resolveIDByPrefix(ctx, client, "thing", "bbbb-b", fetch)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != "bbbbbbbb-1111-2222-3333-444444444444" {
t.Fatalf("got %#v", got)
}
})
t.Run("ambiguous prefix is rejected", func(t *testing.T) {
_, err := resolveIDByPrefix(ctx, client, "thing", "aaaa", fetch)
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("expected ambiguous error, got %v", err)
}
if strings.Contains(err.Error(), "Alpha") || strings.Contains(err.Error(), "Beta") {
t.Fatalf("ambiguous error exposed candidate display/detail: %v", err)
}
})
t.Run("too short prefix is rejected", func(t *testing.T) {
if _, err := resolveIDByPrefix(ctx, client, "thing", "aaa", fetch); err == nil || !strings.Contains(err.Error(), "at least 4") {
t.Fatalf("expected short prefix error, got %v", err)
}
})
}
func TestResolveIssueRef(t *testing.T) {
issue := map[string]any{
"id": "1881a167-4bb6-4602-944b-f40ce4192fe6",
"identifier": "MUL-1852",
"title": "Short ID bug",
}
t.Run("identifier is resolved before prefix lookup", func(t *testing.T) {
listCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/issues/MUL-1852":
json.NewEncoder(w).Encode(issue)
case "/api/issues":
listCalled = true
http.Error(w, "should not list", http.StatusTeapot)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
got, err := resolveIssueRef(context.Background(), client, "MUL-1852")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if listCalled {
t.Fatal("identifier path should not call issue list")
}
if got.ID != issue["id"] || got.Display != "MUL-1852" {
t.Fatalf("got %#v", got)
}
})
t.Run("short UUID prefix resolves from workspace issue list", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/issues" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("workspace_id"); got != "ws-1" {
t.Errorf("workspace_id = %q, want ws-1", got)
}
if got := r.URL.Query().Get("include_closed"); got != "true" {
t.Errorf("include_closed = %q, want true", got)
}
if got := r.URL.Query().Get("limit"); got != strconv.Itoa(resolverListPageLimit) {
t.Errorf("limit = %q, want %d", got, resolverListPageLimit)
}
json.NewEncoder(w).Encode(map[string]any{
"issues": []map[string]any{issue},
"total": 1,
})
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
got, err := resolveIssueRef(context.Background(), client, "1881")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != issue["id"] || got.Display != "MUL-1852" {
t.Fatalf("got %#v", got)
}
})
t.Run("bare issue number is not resolved as issue number", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/issues" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(map[string]any{
"issues": []map[string]any{{
"id": "aaaaaaaa-4bb6-4602-944b-f40ce4192fe6",
"identifier": "MUL-1852",
"title": "Should not resolve by number",
}},
"total": 1,
})
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
_, err := resolveIssueRef(context.Background(), client, "1852")
if err == nil {
t.Fatal("expected bare number to be treated only as a UUID prefix")
}
if got := err.Error(); !strings.Contains(got, "id prefix") {
t.Fatalf("expected prefix error, got: %s", got)
}
})
}
func TestFetchAutopilotCandidatesPaginates(t *testing.T) {
page1 := make([]map[string]any, 0, resolverListPageLimit)
for i := 0; i < resolverListPageLimit; i++ {
page1 = append(page1, map[string]any{
"id": fmt.Sprintf("aaaaaaaa-0000-0000-0000-%012x", i),
"title": fmt.Sprintf("autopilot-%d", i),
"status": "active",
})
}
page2 := []map[string]any{{
"id": "bbbbbbbb-0000-0000-0000-000000000000",
"title": "final autopilot",
"status": "paused",
}}
var offsets []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/autopilots" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("workspace_id"); got != "ws-1" {
t.Errorf("workspace_id = %q, want ws-1", got)
}
if got := r.URL.Query().Get("limit"); got != strconv.Itoa(resolverListPageLimit) {
t.Errorf("limit = %q, want %d", got, resolverListPageLimit)
}
offset := r.URL.Query().Get("offset")
offsets = append(offsets, offset)
switch offset {
case "":
json.NewEncoder(w).Encode(map[string]any{
"autopilots": page1,
"total": resolverListPageLimit + 1,
})
case strconv.Itoa(resolverListPageLimit):
json.NewEncoder(w).Encode(map[string]any{
"autopilots": page2,
"total": resolverListPageLimit + 1,
})
default:
t.Fatalf("unexpected offset %q", offset)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
got, err := fetchAutopilotCandidates(context.Background(), client)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != resolverListPageLimit+1 {
t.Fatalf("got %d candidates, want %d", len(got), resolverListPageLimit+1)
}
if len(offsets) != 2 || offsets[0] != "" || offsets[1] != strconv.Itoa(resolverListPageLimit) {
t.Fatalf("offsets = %#v, want [\"\", %q]", offsets, strconv.Itoa(resolverListPageLimit))
}
if got[len(got)-1].ID != "bbbbbbbb-0000-0000-0000-000000000000" {
t.Fatalf("last candidate = %#v", got[len(got)-1])
}
}
func TestResolveTaskRunID(t *testing.T) {
issueID := "1881a167-4bb6-4602-944b-f40ce4192fe6"
taskID := "abcd1234-0000-0000-0000-000000000000"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/issues/" + issueID + "/task-runs":
json.NewEncoder(w).Encode([]map[string]any{{
"id": taskID,
"agent_id": "agent-1",
"status": "completed",
}})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
got, err := resolveTaskRunID(context.Background(), client, issueID, "abcd")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ID != taskID {
t.Fatalf("got %#v, want task id %s", got, taskID)
}
_, err = resolveTaskRunID(context.Background(), client, "", "abcd")
if err == nil || !strings.Contains(err.Error(), "--issue") {
t.Fatalf("expected missing --issue error for short prefix, got %v", err)
}
}
func TestRunIssueRunMessagesResolvesShortTaskPrefix(t *testing.T) {
issueID := "1881a167-4bb6-4602-944b-f40ce4192fe6"
taskID := "abcd1234-0000-0000-0000-000000000000"
var messagePath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/issues/MUL-1852":
json.NewEncoder(w).Encode(map[string]any{
"id": issueID,
"identifier": "MUL-1852",
})
case "/api/issues/" + issueID + "/task-runs":
json.NewEncoder(w).Encode([]map[string]any{{"id": taskID}})
case "/api/tasks/" + taskID + "/messages":
messagePath = r.URL.Path
json.NewEncoder(w).Encode([]map[string]any{{
"seq": 1,
"type": "text",
"content": "done",
}})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := &cobra.Command{Use: "run-messages"}
cmd.Flags().String("output", "json", "")
cmd.Flags().Int("since", 0, "")
cmd.Flags().String("issue", "", "")
_ = cmd.Flags().Set("issue", "MUL-1852")
if err := runIssueRunMessages(cmd, []string{"abcd"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if messagePath != "/api/tasks/"+taskID+"/messages" {
t.Fatalf("message path = %q, want user-facing task messages path", messagePath)
}
}
func TestResolveAssignee(t *testing.T) {
membersResp := []map[string]any{
{"user_id": "user-1111", "name": "Alice Smith"},
{"user_id": "user-2222", "name": "Bob Jones"},
}
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 {
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)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
t.Run("exact match member", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "Alice Smith", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "member" || aID != "user-1111" {
t.Errorf("got (%q, %q), want (member, user-1111)", aType, aID)
}
})
t.Run("case-insensitive substring", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "bob", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "member" || aID != "user-2222" {
t.Errorf("got (%q, %q), want (member, user-2222)", aType, aID)
}
})
t.Run("match agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "codebot", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "agent-3333" {
t.Errorf("got (%q, %q), want (agent, agent-3333)", aType, aID)
}
})
// 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("match squad by bare @ display 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("no match", func(t *testing.T) {
_, _, err := resolveAssignee(ctx, client, "nobody", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for no match")
}
})
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)
if err == nil {
t.Fatal("expected error for ambiguous match")
}
if got := err.Error(); !strings.Contains(got, "ambiguous") {
t.Errorf("expected ambiguous error, got: %s", got)
}
})
t.Run("missing workspace ID", func(t *testing.T) {
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
_, _, err := resolveAssignee(ctx, noWSClient, "alice", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for missing workspace ID")
}
})
}
func TestNormalizeAssigneeLookupInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"plain name", "Super Human", "Super Human"},
{"bare at name", "@Super Human", "Super Human"},
{"fullwidth at name", "@独立团", "独立团"},
{"spaced at name", " @ Super Human ", "Super Human"},
{
name: "mention link",
in: "[@Super Human](mention://squad/ccccccc1-2222-3333-4444-555555555555)",
want: "ccccccc1-2222-3333-4444-555555555555",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeAssigneeLookupInput(tt.in); got != tt.want {
t.Errorf("normalizeAssigneeLookupInput(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
// 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
// short-circuit substring matching instead of erroring out as ambiguous.
func TestResolveAssigneeExactMatchWins(t *testing.T) {
agentsResp := []map[string]any{
{"id": "f656eab8-1111-1111-1111-111111111111", "name": "reviewer"},
{"id": "9b0ff9a2-2222-2222-2222-222222222222", "name": "peer-reviewer"},
}
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([]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)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
t.Run("exact shorter name resolves to shorter agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "reviewer", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q), want (agent, f656eab8-...)", aType, aID)
}
})
t.Run("exact longer name still resolves unambiguously", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "peer-reviewer", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "9b0ff9a2-2222-2222-2222-222222222222" {
t.Errorf("got (%q, %q), want (agent, 9b0ff9a2-...)", aType, aID)
}
})
t.Run("exact match is case-insensitive and tolerates whitespace", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, " Reviewer ", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q), want exact reviewer agent", aType, aID)
}
})
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)
if err == nil {
t.Fatal("expected error for ambiguous substring match")
}
if got := err.Error(); !strings.Contains(got, "ambiguous") {
t.Errorf("expected ambiguous error, got: %s", got)
}
})
}
// TestResolveAssigneeByID covers the ID/ShortID escape hatch from
// multica-ai/multica#1620: passing a full UUID or its 8-char prefix must
// resolve directly without going through name matching.
func TestResolveAssigneeByID(t *testing.T) {
membersResp := []map[string]any{
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
}
agentsResp := []map[string]any{
{"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)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
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)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q), want reviewer agent", aType, aID)
}
})
t.Run("8-char ShortID resolves agent", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "f656eab8", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q), want reviewer agent", aType, aID)
}
})
t.Run("uppercase ShortID still resolves", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "F656EAB8", issueAssigneeKinds)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if aType != "agent" || aID != "f656eab8-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q), want reviewer agent", aType, aID)
}
})
t.Run("ShortID resolves a member", func(t *testing.T) {
aType, aID, err := resolveAssignee(ctx, client, "aaaaaaaa", issueAssigneeKinds)
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)
}
})
}
// 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"},
}
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)
}
}))
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", issueAssigneeKinds)
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", issueAssigneeKinds)
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", issueAssigneeKinds)
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)
}
})
// 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)
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", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for ShortID")
}
})
t.Run("well-formed UUID with no matching entity errors", func(t *testing.T) {
_, _, err := resolveAssigneeByID(ctx, client, "deadbeef-1111-1111-1111-111111111111", issueAssigneeKinds)
if err == nil {
t.Fatal("expected error for missing entity")
}
if !strings.Contains(err.Error(), "no member, agent, or squad") {
t.Errorf("expected not-found error, got: %v", err)
}
})
t.Run("missing workspace ID", func(t *testing.T) {
noWSClient := cli.NewAPIClient(srv.URL, "", "test-token")
_, _, err := resolveAssigneeByID(ctx, noWSClient, "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", issueAssigneeKinds)
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)
case "/api/squads":
json.NewEncoder(w).Encode([]map[string]any{})
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", issueAssigneeKinds)
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", issueAssigneeKinds)
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q, %v, %v), want Alice", typ, id, has, err)
}
})
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)
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", issueAssigneeKinds)
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", issueAssigneeKinds)
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", issueAssigneeKinds)
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", issueAssigneeKinds)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected mutually-exclusive error, got: %v", err)
}
})
}
// TestPickAssigneeFromFlagsMemberOrAgentKinds is the call-site regression
// for the MUL-2165 follow-up. Subscriber add/remove and project lead pass
// memberOrAgentKinds because their target schema rejects squads
// (subscriber: server/internal/handler/handler.go:414;
// project: server/migrations/034_projects.up.sql:10). Without this gating,
// `multica issue subscriber add --user "<SquadName>"` or
// `multica project create --lead "<SquadName>"` would resolve to
// (squad, ...) and surface as a 500/403 server-side instead of a clean
// CLI-side resolution error.
func TestPickAssigneeFromFlagsMemberOrAgentKinds(t *testing.T) {
membersResp := []map[string]any{
{"user_id": "aaaaaaaa-1111-1111-1111-111111111111", "name": "Alice"},
}
agentsResp := []map[string]any{
{"id": "5fb87ac7-23b5-4a7a-81fa-ed295a54545d", "name": "J"},
}
squadsResp := []map[string]any{
{"id": "ccccccc1-2222-3333-4444-555555555555", "name": "Super Human"},
}
var squadsHits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/workspaces/ws-1/members":
json.NewEncoder(w).Encode(membersResp)
case "/api/agents":
json.NewEncoder(w).Encode(agentsResp)
case "/api/squads":
squadsHits++
json.NewEncoder(w).Encode(squadsResp)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
newCmd := func(nameFlag, idFlag string) *cobra.Command {
c := &cobra.Command{Use: "test"}
c.Flags().String(nameFlag, "", "")
c.Flags().String(idFlag, "", "")
return c
}
t.Run("subscriber --user with a squad name is rejected without hitting /api/squads", func(t *testing.T) {
before := squadsHits
c := newCmd("user", "user-id")
_ = c.Flags().Set("user", "Super Human")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
if err == nil {
t.Fatal("expected resolution error for squad name under memberOrAgentKinds")
}
if !strings.Contains(err.Error(), "no member or agent") {
t.Errorf("expected member-or-agent error wording, got: %v", err)
}
if squadsHits != before {
t.Errorf("memberOrAgentKinds must NOT fetch /api/squads, but it was called %d time(s)", squadsHits-before)
}
})
t.Run("subscriber --user-id with a squad UUID is rejected", func(t *testing.T) {
c := newCmd("user", "user-id")
_ = c.Flags().Set("user-id", "ccccccc1-2222-3333-4444-555555555555")
_, _, _, err := pickAssigneeFromFlags(ctx, client, c, "user", "user-id", memberOrAgentKinds)
if err == nil {
t.Fatal("expected not-found error for squad UUID under memberOrAgentKinds")
}
if !strings.Contains(err.Error(), "no member or agent") {
t.Errorf("expected member-or-agent error wording, got: %v", err)
}
})
t.Run("project --lead with a member name still resolves cleanly", func(t *testing.T) {
c := newCmd("lead", "lead-id")
_ = c.Flags().Set("lead", "Alice")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
if err != nil || !has || typ != "member" || id != "aaaaaaaa-1111-1111-1111-111111111111" {
t.Errorf("got (%q, %q, %v, %v), want member Alice", typ, id, has, err)
}
})
t.Run("project --lead with an agent name still resolves cleanly", func(t *testing.T) {
c := newCmd("lead", "lead-id")
_ = c.Flags().Set("lead", "J")
typ, id, has, err := pickAssigneeFromFlags(ctx, client, c, "lead", "lead-id", memberOrAgentKinds)
if err != nil || !has || typ != "agent" || id != "5fb87ac7-23b5-4a7a-81fa-ed295a54545d" {
t.Errorf("got (%q, %q, %v, %v), want agent J", typ, id, has, err)
}
})
}
func TestIssueSubscriberList(t *testing.T) {
subscribersResp := []map[string]any{
{
"issue_id": "issue-1",
"user_type": "member",
"user_id": "user-1111",
"reason": "creator",
"created_at": "2026-04-01T10:00:00Z",
},
{
"issue_id": "issue-1",
"user_type": "agent",
"user_id": "agent-3333",
"reason": "manual",
"created_at": "2026-04-01T11:00:00Z",
},
}
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.Method != http.MethodGet {
t.Errorf("expected GET, got %s", r.Method)
}
json.NewEncoder(w).Encode(subscribersResp)
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
var got []map[string]any
if err := client.GetJSON(ctx, "/api/issues/issue-1/subscribers", &got); err != nil {
t.Fatalf("GetJSON: %v", err)
}
if gotPath != "/api/issues/issue-1/subscribers" {
t.Errorf("unexpected path: %s", gotPath)
}
if len(got) != 2 {
t.Fatalf("expected 2 subscribers, got %d", len(got))
}
if got[0]["user_type"] != "member" || got[1]["user_type"] != "agent" {
t.Errorf("unexpected subscriber ordering: %+v", got)
}
}
func TestIssueSubscriberMutationBody(t *testing.T) {
tests := []struct {
name string
action string
user string
members []map[string]any
agents []map[string]any
wantPath string
wantBody map[string]any
}{
{
name: "subscribe caller (no user flag)",
action: "subscribe",
user: "",
wantPath: "/api/issues/issue-1/subscribe",
wantBody: map[string]any{},
},
{
name: "unsubscribe caller",
action: "unsubscribe",
user: "",
wantPath: "/api/issues/issue-1/unsubscribe",
wantBody: map[string]any{},
},
{
name: "subscribe a member by name",
action: "subscribe",
user: "alice",
members: []map[string]any{{"user_id": "user-1111", "name": "Alice Smith"}},
wantPath: "/api/issues/issue-1/subscribe",
wantBody: map[string]any{"user_type": "member", "user_id": "user-1111"},
},
{
name: "subscribe an agent by name",
action: "subscribe",
user: "codebot",
agents: []map[string]any{{"id": "agent-3333", "name": "CodeBot"}},
wantPath: "/api/issues/issue-1/subscribe",
wantBody: map[string]any{"user_type": "agent", "user_id": "agent-3333"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPath string
var gotBody map[string]any
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(tt.members)
return
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 {
t.Errorf("expected POST, got %s", r.Method)
}
json.NewDecoder(r.Body).Decode(&gotBody)
json.NewEncoder(w).Encode(map[string]bool{"subscribed": tt.action == "subscribe"})
}))
defer srv.Close()
client := cli.NewAPIClient(srv.URL, "ws-1", "test-token")
ctx := context.Background()
body := map[string]any{}
if tt.user != "" {
uType, uID, err := resolveAssignee(ctx, client, tt.user, issueAssigneeKinds)
if err != nil {
t.Fatalf("resolveAssignee: %v", err)
}
body["user_type"] = uType
body["user_id"] = uID
}
var result map[string]any
path := "/api/issues/issue-1/" + tt.action
if err := client.PostJSON(ctx, path, body, &result); err != nil {
t.Fatalf("PostJSON: %v", err)
}
if gotPath != tt.wantPath {
t.Errorf("path = %q, want %q", gotPath, tt.wantPath)
}
for k, want := range tt.wantBody {
if gotBody[k] != want {
t.Errorf("body[%q] = %v, want %v", k, gotBody[k], want)
}
}
if len(tt.wantBody) == 0 && len(gotBody) != 0 {
t.Errorf("expected empty body, got %+v", gotBody)
}
})
}
}
// newIssueCommentListTestCmd mirrors the flag set wired in main.init() for
// the comment list command. We replicate it here so the runIssueCommentList
// guards can be exercised in isolation — the real command tree pulls in the
// daemon init path.
func newIssueCommentListTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("output", "json", "")
cmd.Flags().String("since", "", "")
cmd.Flags().Bool("roots-only", false, "")
cmd.Flags().Bool("summary", false, "")
cmd.Flags().String("thread", "", "")
cmd.Flags().Int("recent", 0, "")
cmd.Flags().Int("tail", 0, "")
cmd.Flags().String("before", "", "")
cmd.Flags().String("before-id", "", "")
return cmd
}
// TestRunIssueCommentListFlagGuards locks the CLI-side flag combination
// matrix. Three behaviours matter here:
//
// - --recent 0 / --recent -3 must error rather than silently fall back to
// the default list path. Previously `recent > 0` collapsed "not passed"
// and "passed an invalid value" into the same branch; using
// Flags().Changed("recent") distinguishes them so an explicit non-
// positive value is rejected.
// - --before / --before-id without --recent must error. Before this fix
// the cursor would be sent to the server but ignored because RecentN=0,
// so callers asking for "comments before X" got the full timeline.
// - --roots-only is mutually exclusive with the thread/recent/pagination
// modes; it may only combine with --since.
//
// These cases must fail before any HTTP round-trip — verified by an
// httptest server that fatals if /api/issues/<key>/comments is hit.
func TestRunIssueCommentListFlagGuards(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// resolveIssueRef hits GET /api/issues/<ref>; everything else means
// the guard let an invalid combination through to the wire.
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cases := []struct {
name string
setup func(c *cobra.Command)
wantMsg string
}{
{
name: "explicit zero recent rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("recent", "0")
},
wantMsg: "--recent must be a positive integer",
},
{
name: "negative recent rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("recent", "-3")
},
wantMsg: "--recent must be a positive integer",
},
{
name: "before + before-id without recent or thread+tail rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("before", "2026-01-01T00:00:00Z")
_ = c.Flags().Set("before-id", "00000000-0000-0000-0000-000000000001")
},
wantMsg: "require --recent",
},
{
name: "thread + recent still rejected when --recent explicit zero", // also covers the Changed() path
setup: func(c *cobra.Command) {
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
_ = c.Flags().Set("recent", "5")
},
wantMsg: "--thread and --recent are mutually exclusive",
},
{
// --tail is a thread-scoped limit. Outside of --thread it
// would be silently dropped at the server, so the CLI rejects
// it locally with a clear hint.
name: "tail without thread rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("tail", "5")
},
wantMsg: "--tail requires --thread",
},
{
// tail=0 is allowed (root-only) but a negative value would
// round-trip to LIMIT -N on the server. Catch at the CLI so
// the user sees a useful message instead of a 400.
name: "negative tail rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
_ = c.Flags().Set("tail", "-2")
},
wantMsg: "--tail must be a non-negative integer",
},
{
// --thread + --before without --tail used to be rejected
// outright. Now it requires --tail so the cursor's "scroll
// older replies" semantics has somewhere to land.
name: "thread + before without tail rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
_ = c.Flags().Set("before", "2026-01-01T00:00:00Z")
_ = c.Flags().Set("before-id", "00000000-0000-0000-0000-000000000002")
},
wantMsg: "require --recent",
},
{
name: "roots-only + thread rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("roots-only", "true")
_ = c.Flags().Set("thread", "00000000-0000-0000-0000-000000000001")
},
wantMsg: "--roots-only and --thread are mutually exclusive",
},
{
name: "roots-only + recent rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("roots-only", "true")
_ = c.Flags().Set("recent", "3")
},
wantMsg: "--roots-only and --recent are mutually exclusive",
},
{
name: "roots-only + tail rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("roots-only", "true")
_ = c.Flags().Set("tail", "3")
},
wantMsg: "--roots-only and --tail are mutually exclusive",
},
{
name: "roots-only + before rejected",
setup: func(c *cobra.Command) {
_ = c.Flags().Set("roots-only", "true")
_ = c.Flags().Set("before", "2026-01-01T00:00:00Z")
_ = c.Flags().Set("before-id", "00000000-0000-0000-0000-000000000001")
},
wantMsg: "--roots-only does not support --before / --before-id",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newIssueCommentListTestCmd()
tc.setup(cmd)
err := runIssueCommentList(cmd, []string{"MUL-1"})
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantMsg)
}
if !strings.Contains(err.Error(), tc.wantMsg) {
t.Fatalf("error = %q, want substring %q", err.Error(), tc.wantMsg)
}
})
}
}
// TestRunIssueCommentList_RootsOnlyPassesThroughWithSince pins the CLI side
// of #3164: the flag must be forwarded as the server's roots_only query param,
// and it must still allow the existing --since incremental polling filter.
func TestRunIssueCommentList_RootsOnlyPassesThroughWithSince(t *testing.T) {
var gotQuery url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/comments") {
gotQuery = r.URL.Query()
w.Write([]byte("[]"))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newIssueCommentListTestCmd()
if err := cmd.Flags().Set("roots-only", "true"); err != nil {
t.Fatalf("set roots-only: %v", err)
}
if err := cmd.Flags().Set("since", "2026-01-01T00:00:00Z"); err != nil {
t.Fatalf("set since: %v", err)
}
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
t.Fatalf("runIssueCommentList: %v", err)
}
if got := gotQuery.Get("roots_only"); got != "true" {
t.Errorf("roots_only query = %q, want true", got)
}
if got := gotQuery.Get("since"); got != "2026-01-01T00:00:00Z" {
t.Errorf("since query = %q, want timestamp", got)
}
}
// TestRunIssueCommentList_SummaryPassesThrough pins the CLI side of the summary
// projection: --summary must forward summary=true, and it must compose with
// --roots-only (the orientation read it pairs with most often) rather than
// being rejected as an incompatible combination.
func TestRunIssueCommentList_SummaryPassesThrough(t *testing.T) {
var gotQuery url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/comments") {
gotQuery = r.URL.Query()
w.Write([]byte("[]"))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
cmd := newIssueCommentListTestCmd()
if err := cmd.Flags().Set("summary", "true"); err != nil {
t.Fatalf("set summary: %v", err)
}
if err := cmd.Flags().Set("roots-only", "true"); err != nil {
t.Fatalf("set roots-only: %v", err)
}
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
t.Fatalf("runIssueCommentList: %v", err)
}
if got := gotQuery.Get("summary"); got != "true" {
t.Errorf("summary query = %q, want true", got)
}
if got := gotQuery.Get("roots_only"); got != "true" {
t.Errorf("roots_only query = %q, want true", got)
}
}
// TestRunIssueCommentList_ThreadTailPassesThroughAndPrintsReplyCursor pins
// the positive path for --thread + --tail: the CLI forwards `thread` +
// `tail` query params and, on response, prints "Next reply cursor" (not
// "Next thread cursor") so an operator can scroll older replies inside
// the same thread without guessing which cursor model the server emitted.
func TestRunIssueCommentList_ThreadTailPassesThroughAndPrintsReplyCursor(t *testing.T) {
var gotQuery url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/comments") {
gotQuery = r.URL.Query()
// Emit a cursor so we can prove the CLI labels it "reply"
// when the call was a --thread + --tail combo.
w.Header().Set("X-Multica-Next-Before", "2026-01-01T00:00:00.000000001Z")
w.Header().Set("X-Multica-Next-Before-Id", "00000000-0000-0000-0000-000000000999")
w.Write([]byte("[]"))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
http.Error(w, "unexpected", http.StatusInternalServerError)
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
// Redirect stderr so we can assert on the "Next reply cursor" line —
// that's the user-visible signal that the CLI knew it was paging
// within a thread, not across threads.
stderr := captureStderr(t)
defer stderr.restore()
cmd := newIssueCommentListTestCmd()
if err := cmd.Flags().Set("thread", "00000000-0000-0000-0000-000000000001"); err != nil {
t.Fatalf("set thread: %v", err)
}
if err := cmd.Flags().Set("tail", "5"); err != nil {
t.Fatalf("set tail: %v", err)
}
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
t.Fatalf("runIssueCommentList: %v", err)
}
if got := gotQuery.Get("thread"); got != "00000000-0000-0000-0000-000000000001" {
t.Errorf("thread query = %q, want the passed anchor", got)
}
if got := gotQuery.Get("tail"); got != "5" {
t.Errorf("tail query = %q, want %q", got, "5")
}
out := stderr.read()
if !strings.Contains(out, "Next reply cursor: --before 2026-01-01T00:00:00.000000001Z --before-id 00000000-0000-0000-0000-000000000999") {
t.Errorf("stderr missing reply-cursor line, got: %q", out)
}
}
// TestRunIssueCommentList_RecentStillLabelsCursorAsThread is the negative
// counterpart: under --recent the CLI must keep printing "Next thread
// cursor". A regression that printed "reply" here would mis-signal the
// cursor semantics to anyone copy-pasting it.
func TestRunIssueCommentList_RecentStillLabelsCursorAsThread(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
w.Header().Set("X-Multica-Next-Before", "2026-01-01T00:00:00.000000001Z")
w.Header().Set("X-Multica-Next-Before-Id", "00000000-0000-0000-0000-000000000777")
w.Write([]byte("[]"))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
stderr := captureStderr(t)
defer stderr.restore()
cmd := newIssueCommentListTestCmd()
if err := cmd.Flags().Set("recent", "3"); err != nil {
t.Fatalf("set recent: %v", err)
}
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
t.Fatalf("runIssueCommentList: %v", err)
}
out := stderr.read()
if !strings.Contains(out, "Next thread cursor:") {
t.Errorf("stderr missing thread-cursor line, got: %q", out)
}
}
// TestRunIssueCommentList_DoesNotPrintShowingPreamble locks in the removal of
// the "Showing N comments." stderr preamble. The line was the only
// `list --output json` subcommand that emitted a human-readable count, which
// polluted stdout/stderr-merged consumers (agent harnesses, CI `2>&1`).
// Tracks GitHub issue #3303.
func TestRunIssueCommentList_DoesNotPrintShowingPreamble(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/") && !strings.Contains(r.URL.Path, "/comments") {
json.NewEncoder(w).Encode(map[string]any{
"id": "issue-1",
"identifier": "MUL-1",
})
return
}
w.Write([]byte(`[{"id":"c1"},{"id":"c2"}]`))
}))
defer srv.Close()
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
t.Setenv("MULTICA_TOKEN", "test-token")
stderr := captureStderr(t)
defer stderr.restore()
cmd := newIssueCommentListTestCmd()
if err := cmd.Flags().Set("output", "json"); err != nil {
t.Fatalf("set output: %v", err)
}
if err := runIssueCommentList(cmd, []string{"MUL-1"}); err != nil {
t.Fatalf("runIssueCommentList: %v", err)
}
if got := stderr.read(); strings.Contains(got, "Showing") {
t.Errorf("stderr must not contain a 'Showing ...' preamble, got: %q", got)
}
}
func TestValidIssueStatuses(t *testing.T) {
expected := map[string]bool{
"backlog": true,
"todo": true,
"in_progress": true,
"in_review": true,
"done": true,
"blocked": true,
"cancelled": true,
}
for _, s := range validIssueStatuses {
if !expected[s] {
t.Errorf("unexpected status in validIssueStatuses: %q", s)
}
}
if len(validIssueStatuses) != len(expected) {
t.Errorf("validIssueStatuses has %d entries, expected %d", len(validIssueStatuses), len(expected))
}
}
func TestValidateIssueStatus(t *testing.T) {
for _, s := range validIssueStatuses {
if err := validateIssueStatus(s); err != nil {
t.Errorf("status %q should be valid, got: %v", s, err)
}
}
err := validateIssueStatus("active")
if err == nil {
t.Fatal("status \"active\" should be rejected")
}
if !strings.Contains(err.Error(), "backlog") {
t.Errorf("error should list valid statuses, got: %v", err)
}
}
func TestValidateIssuePriority(t *testing.T) {
expected := map[string]bool{
"urgent": true,
"high": true,
"medium": true,
"low": true,
"none": true,
}
for _, p := range validIssuePriorities {
if !expected[p] {
t.Errorf("unexpected priority in validIssuePriorities: %q", p)
}
if err := validateIssuePriority(p); err != nil {
t.Errorf("priority %q should be valid, got: %v", p, err)
}
}
if len(validIssuePriorities) != len(expected) {
t.Errorf("validIssuePriorities has %d entries, expected %d", len(validIssuePriorities), len(expected))
}
err := validateIssuePriority("P1")
if err == nil {
t.Fatal("priority \"P1\" should be rejected")
}
if !strings.Contains(err.Error(), "urgent") {
t.Errorf("error should list valid priorities, got: %v", err)
}
}
func TestRunIssueCreateRejectsInvalidStatusBeforeRequest(t *testing.T) {
cmd := newIssueCreateTestCmd()
_ = cmd.Flags().Set("title", "Invalid status")
_ = cmd.Flags().Set("status", "active")
err := runIssueCreate(cmd, nil)
if err == nil {
t.Fatal("runIssueCreate should reject invalid status")
}
if !strings.Contains(err.Error(), "valid values") {
t.Fatalf("expected valid values error, got: %v", err)
}
}
func TestRunIssueCreateRejectsInvalidPriorityBeforeRequest(t *testing.T) {
cmd := newIssueCreateTestCmd()
_ = cmd.Flags().Set("title", "Invalid priority")
_ = cmd.Flags().Set("priority", "P1")
err := runIssueCreate(cmd, nil)
if err == nil {
t.Fatal("runIssueCreate should reject invalid priority")
}
if !strings.Contains(err.Error(), "valid values") {
t.Fatalf("expected valid values error, got: %v", err)
}
}
func TestRunIssueUpdateRejectsInvalidStatusBeforeRequest(t *testing.T) {
cmd := &cobra.Command{Use: "update"}
cmd.Flags().String("status", "", "")
cmd.Flags().String("priority", "", "")
_ = cmd.Flags().Set("status", "active")
err := runIssueUpdate(cmd, []string{"MUL-1"})
if err == nil {
t.Fatal("runIssueUpdate should reject invalid status")
}
if !strings.Contains(err.Error(), "valid values") {
t.Fatalf("expected valid values error, got: %v", err)
}
}
func TestRunIssueUpdateRejectsInvalidPriorityBeforeRequest(t *testing.T) {
cmd := &cobra.Command{Use: "update"}
cmd.Flags().String("status", "", "")
cmd.Flags().String("priority", "", "")
_ = cmd.Flags().Set("priority", "P1")
err := runIssueUpdate(cmd, []string{"MUL-1"})
if err == nil {
t.Fatal("runIssueUpdate should reject invalid priority")
}
if !strings.Contains(err.Error(), "valid values") {
t.Fatalf("expected valid values error, got: %v", err)
}
}