Compare commits

...

1 Commits

Author SHA1 Message Date
J
7648a36f1f feat: add CLI comment resolve commands
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 16:46:10 +08:00
4 changed files with 188 additions and 0 deletions

View File

@@ -166,6 +166,20 @@ var issueCommentDeleteCmd = &cobra.Command{
RunE: runIssueCommentDelete,
}
var issueCommentResolveCmd = &cobra.Command{
Use: "resolve <comment-id>",
Short: "Resolve a comment thread",
Args: exactArgs(1),
RunE: runIssueCommentResolve,
}
var issueCommentUnresolveCmd = &cobra.Command{
Use: "unresolve <comment-id>",
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
// ---------------------------------------------------------------------------

View File

@@ -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:
//

View File

@@ -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
}

View File

@@ -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