diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index e7783354..9ce65c1e 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -147,6 +147,7 @@ func init() { // issue comment add issueCommentAddCmd.Flags().String("content", "", "Comment content (required)") issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)") + 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") } @@ -540,19 +541,45 @@ func runIssueCommentAdd(cmd *cobra.Command, args []string) error { return err } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + issueID := args[0] + + // Use a longer timeout when attachments are present (file uploads can be slow). + timeout := 15 * time.Second + attachments, _ := cmd.Flags().GetStringSlice("attachment") + if len(attachments) > 0 { + timeout = 60 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + // Upload attachments and collect their IDs. + var attachmentIDs []string + for _, filePath := range attachments { + data, readErr := os.ReadFile(filePath) + if readErr != nil { + return fmt.Errorf("read attachment %s: %w", filePath, readErr) + } + id, uploadErr := client.UploadFile(ctx, data, filePath, issueID) + if uploadErr != nil { + return fmt.Errorf("upload attachment %s: %w", filePath, uploadErr) + } + attachmentIDs = append(attachmentIDs, id) + fmt.Fprintf(os.Stderr, "Uploaded %s\n", filePath) + } + body := map[string]any{"content": content} if parentID, _ := cmd.Flags().GetString("parent"); parentID != "" { body["parent_id"] = parentID } + if len(attachmentIDs) > 0 { + body["attachment_ids"] = attachmentIDs + } var result map[string]any - if err := client.PostJSON(ctx, "/api/issues/"+args[0]+"/comments", body, &result); err != nil { + if err := client.PostJSON(ctx, "/api/issues/"+issueID+"/comments", body, &result); err != nil { return fmt.Errorf("add comment: %w", err) } - fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(args[0])) + fmt.Fprintf(os.Stderr, "Comment added to issue %s.\n", truncateID(issueID)) output, _ := cmd.Flags().GetString("output") if output == "table" { diff --git a/server/internal/cli/client.go b/server/internal/cli/client.go index 312276d0..8cfce31b 100644 --- a/server/internal/cli/client.go +++ b/server/internal/cli/client.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "path/filepath" "strings" "time" ) @@ -156,6 +158,60 @@ func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) return json.NewDecoder(resp.Body).Decode(out) } +// UploadFile uploads a file via multipart form to /api/upload-file. +// It returns the attachment ID from the server response. +func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("file", filepath.Base(filename)) + if err != nil { + return "", fmt.Errorf("create form file: %w", err) + } + if _, err := part.Write(fileData); err != nil { + return "", fmt.Errorf("write file data: %w", err) + } + + if issueID != "" { + if err := writer.WriteField("issue_id", issueID); err != nil { + return "", fmt.Errorf("write issue_id field: %w", err) + } + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("close multipart writer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("upload file returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode upload response: %w", err) + } + + id, _ := result["id"].(string) + if id == "" { + return "", fmt.Errorf("upload response missing attachment id") + } + return id, nil +} + // HealthCheck hits the /health endpoint and returns the response body. func (c *APIClient) HealthCheck(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)