From 637b6ee43383f83cd7cc7017bfb7b4e777f23518 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:16:38 +0800 Subject: [PATCH] feat: add CLI comment resolve commands (#4404) Co-authored-by: J Co-authored-by: multica-agent --- server/cmd/multica/cmd_issue.go | 58 +++++++++++++++++++++ server/cmd/multica/cmd_issue_test.go | 77 ++++++++++++++++++++++++++++ server/internal/cli/client.go | 8 +++ server/internal/cli/client_test.go | 45 ++++++++++++++++ 4 files changed, 188 insertions(+) diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 4cc9cb56c..880c2f890 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -166,6 +166,20 @@ var issueCommentDeleteCmd = &cobra.Command{ RunE: runIssueCommentDelete, } +var issueCommentResolveCmd = &cobra.Command{ + Use: "resolve ", + Short: "Resolve a comment thread", + Args: exactArgs(1), + RunE: runIssueCommentResolve, +} + +var issueCommentUnresolveCmd = &cobra.Command{ + Use: "unresolve ", + Short: "Unresolve a comment thread", + Args: exactArgs(1), + RunE: runIssueCommentUnresolve, +} + // Subscriber subcommands. var issueSubscriberCmd = &cobra.Command{ @@ -278,6 +292,8 @@ func init() { issueCommentCmd.AddCommand(issueCommentListCmd) issueCommentCmd.AddCommand(issueCommentAddCmd) issueCommentCmd.AddCommand(issueCommentDeleteCmd) + issueCommentCmd.AddCommand(issueCommentResolveCmd) + issueCommentCmd.AddCommand(issueCommentUnresolveCmd) issueSubscriberCmd.AddCommand(issueSubscriberListCmd) issueSubscriberCmd.AddCommand(issueSubscriberAddCmd) @@ -376,6 +392,10 @@ func init() { issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)") issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json") + // issue comment resolve/unresolve + issueCommentResolveCmd.Flags().String("output", "json", "Output format: table or json") + issueCommentUnresolveCmd.Flags().String("output", "json", "Output format: table or json") + // issue search issueSearchCmd.Flags().Int("limit", 20, "Maximum number of results to return") issueSearchCmd.Flags().Bool("include-closed", false, "Include done and cancelled issues") @@ -1318,6 +1338,44 @@ func runIssueCommentDelete(cmd *cobra.Command, args []string) error { return nil } +func runIssueCommentResolve(cmd *cobra.Command, args []string) error { + return runIssueCommentResolution(cmd, args[0], true) +} + +func runIssueCommentUnresolve(cmd *cobra.Command, args []string) error { + return runIssueCommentResolution(cmd, args[0], false) +} + +func runIssueCommentResolution(cmd *cobra.Command, commentID string, resolve bool) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := cli.APIContext(context.Background()) + defer cancel() + + path := "/api/comments/" + url.PathEscape(commentID) + "/resolve" + var result map[string]any + if resolve { + if err := client.PostJSON(ctx, path, nil, &result); err != nil { + return fmt.Errorf("resolve comment: %w", err) + } + fmt.Fprintf(os.Stderr, "Comment %s resolved.\n", commentID) + } else { + if err := client.DeleteJSONResponse(ctx, path, &result); err != nil { + return fmt.Errorf("unresolve comment: %w", err) + } + fmt.Fprintf(os.Stderr, "Comment %s unresolved.\n", commentID) + } + + output, _ := cmd.Flags().GetString("output") + if output == "table" { + return nil + } + return cli.PrintJSON(os.Stdout, result) +} + // --------------------------------------------------------------------------- // Execution history commands // --------------------------------------------------------------------------- diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go index e7a10d594..bb3740f36 100644 --- a/server/cmd/multica/cmd_issue_test.go +++ b/server/cmd/multica/cmd_issue_test.go @@ -1702,6 +1702,83 @@ func newIssueCommentListTestCmd() *cobra.Command { return cmd } +func newIssueCommentResolutionTestCmd(use string) *cobra.Command { + cmd := &cobra.Command{Use: use} + cmd.Flags().String("output", "json", "") + return cmd +} + +func TestRunIssueCommentResolution(t *testing.T) { + commentID := "comment-123" + tests := []struct { + name string + run func(*cobra.Command, []string) error + cmdUse string + wantMethod string + }{ + { + name: "resolve posts to resolve endpoint", + run: runIssueCommentResolve, + cmdUse: "resolve", + wantMethod: http.MethodPost, + }, + { + name: "unresolve deletes resolve endpoint", + run: runIssueCommentUnresolve, + cmdUse: "unresolve", + wantMethod: http.MethodDelete, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + if gotMethod != tt.wantMethod { + t.Errorf("method = %s, want %s", gotMethod, tt.wantMethod) + } + if gotPath != "/api/comments/"+commentID+"/resolve" { + t.Errorf("path = %q, want /api/comments/%s/resolve", gotPath, commentID) + } + if ws := r.Header.Get("X-Workspace-ID"); ws != "ws-1" { + t.Errorf("X-Workspace-ID = %q, want ws-1", ws) + } + json.NewEncoder(w).Encode(map[string]any{ + "id": commentID, + "content": "done", + "resolved_at": "2026-06-22T08:00:00Z", + }) + })) + defer srv.Close() + + t.Setenv("MULTICA_SERVER_URL", srv.URL) + t.Setenv("MULTICA_WORKSPACE_ID", "ws-1") + t.Setenv("MULTICA_TOKEN", "test-token") + + cmd := newIssueCommentResolutionTestCmd(tt.cmdUse) + out, err := captureStdout(t, func() error { + return tt.run(cmd, []string{commentID}) + }) + if err != nil { + t.Fatalf("run command: %v", err) + } + if gotMethod == "" || gotPath == "" { + t.Fatal("server did not receive request") + } + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode stdout JSON: %v\nstdout: %s", err, out) + } + if got["id"] != commentID { + t.Fatalf("stdout id = %v, want %s", got["id"], commentID) + } + }) + } +} + // TestRunIssueCommentListFlagGuards locks the CLI-side flag combination // matrix. Three behaviours matter here: // diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 07450882d..575610692 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -258,6 +258,11 @@ func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any // DeleteJSON performs a DELETE request. func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { + return c.DeleteJSONResponse(ctx, path, nil) +} + +// DeleteJSONResponse performs a DELETE request and optionally decodes the JSON response. +func (c *APIClient) DeleteJSONResponse(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil) if err != nil { return err @@ -274,6 +279,9 @@ func (c *APIClient) DeleteJSON(ctx context.Context, path string) error { if resp.StatusCode >= 400 { return newHTTPError(http.MethodDelete, path, resp) } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } return nil } diff --git a/server/internal/cli/client_test.go b/server/internal/cli/client_test.go index a5675b704..ac1aaeace 100644 --- a/server/internal/cli/client_test.go +++ b/server/internal/cli/client_test.go @@ -166,6 +166,51 @@ func TestPostJSON(t *testing.T) { }) } +func TestDeleteJSONResponse(t *testing.T) { + type respBody struct { + ID string `json:"id"` + } + + t.Run("success decodes response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer test-token" { + t.Errorf("expected Authorization Bearer test-token, got %s", auth) + } + json.NewEncoder(w).Encode(respBody{ID: "comment-123"}) + })) + defer srv.Close() + + client := NewAPIClient(srv.URL, "", "test-token") + var out respBody + if err := client.DeleteJSONResponse(context.Background(), "/test", &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.ID != "comment-123" { + t.Errorf("expected ID comment-123, got %s", out.ID) + } + }) + + t.Run("error status", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + io.WriteString(w, "missing") + })) + defer srv.Close() + + client := NewAPIClient(srv.URL, "", "test-token") + err := client.DeleteJSONResponse(context.Background(), "/test", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if got := err.Error(); got != "DELETE /test returned 404: missing" { + t.Errorf("unexpected error message: %s", got) + } + }) +} + func TestDownloadFile(t *testing.T) { t.Run("relative URL is resolved against BaseURL and sent with auth", func(t *testing.T) { var gotPath, gotAuth string