mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
feat: add CLI comment resolve commands (#4404)
Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user