Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
ecff3590de fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.

Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
2026-04-17 16:19:54 +08:00
Jiang Bohan
d45cd643dd feat(cli): add issue subscriber commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
2026-04-17 16:06:05 +08:00
5 changed files with 389 additions and 14 deletions

View File

@@ -332,6 +332,27 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
### Subscribers
```bash
# List subscribers of an issue
multica issue subscriber list <issue-id>
# Subscribe yourself to an issue
multica issue subscriber add <issue-id>
# Subscribe another member or agent by name
multica issue subscriber add <issue-id> --user "Lambda"
# Unsubscribe yourself
multica issue subscriber remove <issue-id>
# Unsubscribe another member or agent
multica issue subscriber remove <issue-id> --user "Lambda"
```
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
### Execution History
```bash

View File

@@ -88,6 +88,34 @@ var issueCommentDeleteCmd = &cobra.Command{
RunE: runIssueCommentDelete,
}
// Subscriber subcommands.
var issueSubscriberCmd = &cobra.Command{
Use: "subscriber",
Short: "Work with issue subscribers",
}
var issueSubscriberListCmd = &cobra.Command{
Use: "list <issue-id>",
Short: "List subscribers of an issue",
Args: exactArgs(1),
RunE: runIssueSubscriberList,
}
var issueSubscriberAddCmd = &cobra.Command{
Use: "add <issue-id>",
Short: "Subscribe a user or agent to an issue (defaults to the caller)",
Args: exactArgs(1),
RunE: runIssueSubscriberAdd,
}
var issueSubscriberRemoveCmd = &cobra.Command{
Use: "remove <issue-id>",
Short: "Unsubscribe a user or agent from an issue (defaults to the caller)",
Args: exactArgs(1),
RunE: runIssueSubscriberRemove,
}
// Execution history subcommands.
var issueRunsCmd = &cobra.Command{
@@ -123,6 +151,7 @@ func init() {
issueCmd.AddCommand(issueAssignCmd)
issueCmd.AddCommand(issueStatusCmd)
issueCmd.AddCommand(issueCommentCmd)
issueCmd.AddCommand(issueSubscriberCmd)
issueCmd.AddCommand(issueRunsCmd)
issueCmd.AddCommand(issueRunMessagesCmd)
issueCmd.AddCommand(issueSearchCmd)
@@ -131,6 +160,10 @@ func init() {
issueCommentCmd.AddCommand(issueCommentAddCmd)
issueCommentCmd.AddCommand(issueCommentDeleteCmd)
issueSubscriberCmd.AddCommand(issueSubscriberListCmd)
issueSubscriberCmd.AddCommand(issueSubscriberAddCmd)
issueSubscriberCmd.AddCommand(issueSubscriberRemoveCmd)
// issue list
issueListCmd.Flags().String("output", "table", "Output format: table or json")
issueListCmd.Flags().String("status", "", "Filter by status")
@@ -198,6 +231,17 @@ func init() {
issueSearchCmd.Flags().Int("limit", 20, "Maximum number of results to return")
issueSearchCmd.Flags().Bool("include-closed", false, "Include done and cancelled issues")
issueSearchCmd.Flags().String("output", "table", "Output format: table or json")
// issue subscriber list
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("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("output", "json", "Output format: table or json")
}
// ---------------------------------------------------------------------------
@@ -918,6 +962,100 @@ func runIssueSearch(cmd *cobra.Command, args []string) error {
return nil
}
// ---------------------------------------------------------------------------
// Subscriber commands
// ---------------------------------------------------------------------------
func runIssueSubscriberList(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var subscribers []map[string]any
if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/subscribers", &subscribers); err != nil {
return fmt.Errorf("list subscribers: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, subscribers)
}
headers := []string{"USER_TYPE", "USER_ID", "REASON", "CREATED"}
rows := make([][]string, 0, len(subscribers))
for _, s := range subscribers {
created := strVal(s, "created_at")
if len(created) >= 16 {
created = created[:16]
}
rows = append(rows, []string{
strVal(s, "user_type"),
truncateID(strVal(s, "user_id")),
strVal(s, "reason"),
created,
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runIssueSubscriberAdd(cmd *cobra.Command, args []string) error {
return runIssueSubscriberMutation(cmd, args[0], "subscribe")
}
func runIssueSubscriberRemove(cmd *cobra.Command, args []string) error {
return runIssueSubscriberMutation(cmd, args[0], "unsubscribe")
}
// runIssueSubscriberMutation shares subscribe/unsubscribe logic — both endpoints
// take the same request body and only differ in the path.
func runIssueSubscriberMutation(cmd *cobra.Command, issueID, action string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
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)
}
body["user_type"] = uType
body["user_id"] = uID
}
var result map[string]any
path := "/api/issues/" + issueID + "/" + action
if err := client.PostJSON(ctx, path, body, &result); err != nil {
return fmt.Errorf("%s issue: %w", action, err)
}
target := "caller"
if userName != "" {
target = userName
}
if action == "subscribe" {
fmt.Fprintf(os.Stderr, "Subscribed %s to issue %s.\n", target, truncateID(issueID))
} else {
fmt.Fprintf(os.Stderr, "Unsubscribed %s from issue %s.\n", target, truncateID(issueID))
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
return nil
}
return cli.PrintJSON(os.Stdout, result)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -137,6 +137,150 @@ func TestResolveAssignee(t *testing.T) {
})
}
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
}
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)
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)
}
})
}
}
func TestValidIssueStatuses(t *testing.T) {
expected := map[string]bool{
"backlog": true,
@@ -156,4 +300,3 @@ func TestValidIssueStatuses(t *testing.T) {
t.Errorf("validIssueStatuses has %d entries, expected %d", len(validIssueStatuses), len(expected))
}
}

View File

@@ -59,9 +59,12 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
return
}
// Default to current user as member; allow specifying another user/agent
targetUserID := requestUserID(r)
targetUserType := "member"
workspaceID := uuidToString(issue.WorkspaceID)
// Default target: the caller, derived via resolveActor so an agent caller
// (X-Agent-ID set) subscribes itself rather than the underlying member.
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
targetUserType := callerActorType
targetUserID := callerActorID
var req struct {
UserID *string `json:"user_id"`
UserType *string `json:"user_type"`
@@ -76,7 +79,6 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
targetUserType = *req.UserType
}
workspaceID := uuidToString(issue.WorkspaceID)
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
@@ -93,9 +95,7 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) {
return
}
callerID := requestUserID(r)
subActorType, subActorID := h.resolveActor(r, callerID, workspaceID)
h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{
h.publish(protocol.EventSubscriberAdded, workspaceID, callerActorType, callerActorID, map[string]any{
"issue_id": issueID,
"user_type": targetUserType,
"user_id": targetUserID,
@@ -114,8 +114,12 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
return
}
targetUserID := requestUserID(r)
targetUserType := "member"
workspaceID := uuidToString(issue.WorkspaceID)
// Default target: the caller, derived via resolveActor so an agent caller
// (X-Agent-ID set) unsubscribes itself rather than the underlying member.
callerActorType, callerActorID := h.resolveActor(r, requestUserID(r), workspaceID)
targetUserType := callerActorType
targetUserID := callerActorID
var req struct {
UserID *string `json:"user_id"`
UserType *string `json:"user_type"`
@@ -130,7 +134,6 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
targetUserType = *req.UserType
}
workspaceID := uuidToString(issue.WorkspaceID)
if !h.isWorkspaceEntity(r.Context(), targetUserType, targetUserID, workspaceID) {
writeError(w, http.StatusForbidden, "target user is not a member of this workspace")
return
@@ -146,9 +149,7 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) {
return
}
callerID := requestUserID(r)
unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID)
h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{
h.publish(protocol.EventSubscriberRemoved, workspaceID, callerActorType, callerActorID, map[string]any{
"issue_id": issueID,
"user_type": targetUserType,
"user_id": targetUserID,

View File

@@ -220,6 +220,78 @@ func TestSubscriberAPI(t *testing.T) {
}
})
t.Run("AgentCallerSubscribesItself", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)
// Look up the agent created by the handler test fixture.
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Subscribe with X-Agent-ID set — no body, so the handler must default
// to subscribing the agent itself (not the member behind X-User-ID).
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
req = withURLParam(req, "id", issueID)
req.Header.Set("X-Agent-ID", agentID)
testHandler.SubscribeToIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
}
agentSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "agent",
UserID: parseUUID(agentID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber (agent): %v", err)
}
if !agentSubscribed {
t.Fatal("expected agent to be subscribed in DB when X-Agent-ID is set")
}
memberSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(testUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber (member): %v", err)
}
if memberSubscribed {
t.Fatal("member must not be auto-subscribed when caller is an agent")
}
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
req = withURLParam(req, "id", issueID)
req.Header.Set("X-Agent-ID", agentID)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
}
agentSubscribed, err = testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "agent",
UserID: parseUUID(agentID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber (agent, after unsubscribe): %v", err)
}
if agentSubscribed {
t.Fatal("expected agent to be unsubscribed in DB when X-Agent-ID is set")
}
})
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
issueID := createIssue(t)
defer deleteIssue(t, issueID)