mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 01:49:18 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecff3590de | ||
|
|
d45cd643dd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user