feat(cli): add --attachment flag to issue comment add (#260)

Add file attachment support to `multica issue comment add`. The CLI
uploads files via multipart form to /api/upload-file, collects the
returned attachment IDs, and passes them when creating the comment.

Usage: multica issue comment add <issue-id> --content "..." --attachment file1.png --attachment file2.pdf

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen 2026-04-01 15:57:23 +08:00 committed by GitHub
parent daaa4deaf7
commit 98e7d27acc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 86 additions and 3 deletions

View file

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

View file

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