diff --git a/api/client.go b/api/client.go index 683f4b16..2afe0ac6 100644 --- a/api/client.go +++ b/api/client.go @@ -228,3 +228,14 @@ func ProxyWatchIssue(c *jira.Client, key string, user *jira.User) error { } return c.WatchIssue(key, assignee) } + +// ProxyGetIssueAttachments uses either v2 or v3 version of the Jira API +// to fetch issue attachments based on configured installation type. +func ProxyGetIssueAttachments(c *jira.Client, key string) ([]jira.Attachment, error) { + it := viper.GetString("installation") + + if it == jira.InstallationTypeLocal { + return c.GetIssueAttachmentsV2(key) + } + return c.GetIssueAttachments(key) +} diff --git a/internal/cmd/issue/attachment/attachment.go b/internal/cmd/issue/attachment/attachment.go new file mode 100644 index 00000000..65158c74 --- /dev/null +++ b/internal/cmd/issue/attachment/attachment.go @@ -0,0 +1,28 @@ +package attachment + +import ( + "github.com/spf13/cobra" + + "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/attachment/download" +) + +const helpText = `Attachment command helps you manage issue attachments. See available commands below.` + +// NewCmdAttachment is an attachment command. +func NewCmdAttachment() *cobra.Command { + cmd := cobra.Command{ + Use: "attachment", + Short: "Manage issue attachments", + Long: helpText, + Aliases: []string{"attach", "att"}, + RunE: attachment, + } + + cmd.AddCommand(download.NewCmdDownload()) + + return &cmd +} + +func attachment(cmd *cobra.Command, _ []string) error { + return cmd.Help() +} diff --git a/internal/cmd/issue/attachment/download/download.go b/internal/cmd/issue/attachment/download/download.go new file mode 100644 index 00000000..f8908781 --- /dev/null +++ b/internal/cmd/issue/attachment/download/download.go @@ -0,0 +1,126 @@ +package download + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/internal/cmdutil" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const ( + helpText = `Download downloads all attachments from an issue.` + dirPerm = 0o750 + examples = `$ jira issue attachment download ISSUE-1 + +# Download to a custom directory +$ jira issue attachment download ISSUE-1 --output-dir ./downloads + +# Using short flags +$ jira issue attachment download ISSUE-1 -o ./my-folder` +) + +// NewCmdDownload is a download command. +func NewCmdDownload() *cobra.Command { + cmd := cobra.Command{ + Use: "download ISSUE-KEY", + Short: "Download attachments from an issue", + Long: helpText, + Example: examples, + Annotations: map[string]string{ + "help:args": "ISSUE-KEY\tIssue key, eg: ISSUE-1", + }, + Args: cobra.MinimumNArgs(1), + Run: download, + } + + cmd.Flags().StringP("output-dir", "o", "", "Output directory (default: .//)") + + return &cmd +} + +func download(cmd *cobra.Command, args []string) { + debug, err := cmd.Flags().GetBool("debug") + cmdutil.ExitIfError(err) + + key := cmdutil.GetJiraIssueKey(viper.GetString("project.key"), args[0]) + + outputDir, err := cmd.Flags().GetString("output-dir") + cmdutil.ExitIfError(err) + + if outputDir == "" { + outputDir = "./" + key + } + + client := api.DefaultClient(debug) + + attachments, err := func() ([]jira.Attachment, error) { + s := cmdutil.Info(fmt.Sprintf("Fetching attachments for %s", key)) + defer s.Stop() + + return api.ProxyGetIssueAttachments(client, key) + }() + cmdutil.ExitIfError(err) + + if len(attachments) == 0 { + cmdutil.Success("No attachments found for %s", key) + return + } + + // Create output directory + if err := os.MkdirAll(outputDir, dirPerm); err != nil { + cmdutil.ExitIfError(fmt.Errorf("failed to create directory %s: %w", outputDir, err)) + } + + var ( + downloaded int + failed int + ) + + for _, att := range attachments { + targetPath := filepath.Join(outputDir, att.Filename) + + err := func() error { + s := cmdutil.Info(fmt.Sprintf("Downloading %s (%s)", att.Filename, formatSize(att.Size))) + defer s.Stop() + + return client.DownloadAttachment(att.Content, targetPath) + }() + + if err != nil { + cmdutil.Fail("Failed to download %s: %v", att.Filename, err) + failed++ + } else { + cmdutil.Success("Downloaded %s", att.Filename) + downloaded++ + } + } + + fmt.Println() + if failed > 0 { + cmdutil.Warn("Downloaded %d of %d attachments to %s (%d failed)", downloaded, len(attachments), outputDir, failed) + } else { + cmdutil.Success("Downloaded %d attachments to %s", downloaded, outputDir) + } +} + +func formatSize(bytes int) string { + const ( + KB = 1024 + MB = KB * 1024 + ) + + switch { + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go index 6d0607ca..6ab0b215 100644 --- a/internal/cmd/issue/issue.go +++ b/internal/cmd/issue/issue.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/assign" + "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/attachment" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/clone" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/comment" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue/create" @@ -37,7 +38,7 @@ func NewCmdIssue() *cobra.Command { cmd.AddCommand( lc, cc, edit.NewCmdEdit(), move.NewCmdMove(), view.NewCmdView(), assign.NewCmdAssign(), link.NewCmdLink(), unlink.NewCmdUnlink(), comment.NewCmdComment(), clone.NewCmdClone(), - delete.NewCmdDelete(), watch.NewCmdWatch(), worklog.NewCmdWorklog(), + delete.NewCmdDelete(), watch.NewCmdWatch(), worklog.NewCmdWorklog(), attachment.NewCmdAttachment(), ) list.SetFlags(lc) diff --git a/pkg/jira/attachment.go b/pkg/jira/attachment.go new file mode 100644 index 00000000..b643551e --- /dev/null +++ b/pkg/jira/attachment.go @@ -0,0 +1,95 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +const dirPerm = 0o750 + +// GetIssueAttachments fetches attachments for an issue using v3 API. +func (c *Client) GetIssueAttachments(key string) ([]Attachment, error) { + return c.getIssueAttachments(key, apiVersion3) +} + +// GetIssueAttachmentsV2 fetches attachments for an issue using v2 API. +func (c *Client) GetIssueAttachmentsV2(key string) ([]Attachment, error) { + return c.getIssueAttachments(key, apiVersion2) +} + +func (c *Client) getIssueAttachments(key, ver string) ([]Attachment, error) { + path := fmt.Sprintf("/issue/%s?fields=attachment", key) + + var ( + res *http.Response + err error + ) + + switch ver { + case apiVersion2: + res, err = c.GetV2(context.Background(), path, nil) + default: + res, err = c.Get(context.Background(), path, nil) + } + + if err != nil { + return nil, err + } + if res == nil { + return nil, ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return nil, formatUnexpectedResponse(res) + } + + var issue Issue + if err := json.NewDecoder(res.Body).Decode(&issue); err != nil { + return nil, err + } + + return issue.Fields.Attachment, nil +} + +// DownloadAttachment downloads an attachment from the given URL to the target path. +func (c *Client) DownloadAttachment(contentURL, targetPath string) error { + res, err := c.request(context.Background(), http.MethodGet, contentURL, nil, nil) + if err != nil { + return err + } + if res == nil { + return ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return formatUnexpectedResponse(res) + } + + // Ensure directory exists + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, dirPerm); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Create file + out, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer func() { _ = out.Close() }() + + // Copy content + _, err = io.Copy(out, res.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} diff --git a/pkg/jira/attachment_test.go b/pkg/jira/attachment_test.go new file mode 100644 index 00000000..0ef176e6 --- /dev/null +++ b/pkg/jira/attachment_test.go @@ -0,0 +1,112 @@ +package jira + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetIssueAttachments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path) + assert.Equal(t, "attachment", r.URL.Query().Get("fields")) + + resp, err := os.ReadFile("./testdata/attachments.json") + assert.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + attachments, err := client.GetIssueAttachments("TEST-1") + assert.NoError(t, err) + assert.Len(t, attachments, 2) + assert.Equal(t, "screenshot.png", attachments[0].Filename) + assert.Equal(t, "document.pdf", attachments[1].Filename) + assert.Equal(t, 12345, attachments[0].Size) +} + +func TestGetIssueAttachmentsV2(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path) + assert.Equal(t, "attachment", r.URL.Query().Get("fields")) + + resp, err := os.ReadFile("./testdata/attachments.json") + assert.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write(resp) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + attachments, err := client.GetIssueAttachmentsV2("TEST-1") + assert.NoError(t, err) + assert.Len(t, attachments, 2) +} + +func TestGetIssueAttachments_NoAttachments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"key": "TEST-1", "fields": {"attachment": []}}`)) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + attachments, err := client.GetIssueAttachments("TEST-1") + assert.NoError(t, err) + assert.Len(t, attachments, 0) +} + +func TestDownloadAttachment(t *testing.T) { + expectedContent := []byte("test file content") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(200) + _, _ = w.Write(expectedContent) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + // Create temp directory + tmpDir := t.TempDir() + targetPath := filepath.Join(tmpDir, "test-download.txt") + + err := client.DownloadAttachment(server.URL+"/content", targetPath) + assert.NoError(t, err) + + // Verify file exists and has correct content + content, err := os.ReadFile(targetPath) + assert.NoError(t, err) + assert.Equal(t, expectedContent, content) +} + +func TestDownloadAttachment_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(404) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + tmpDir := t.TempDir() + targetPath := filepath.Join(tmpDir, "test-download.txt") + + err := client.DownloadAttachment(server.URL+"/content", targetPath) + assert.Error(t, err) +} diff --git a/pkg/jira/testdata/attachments.json b/pkg/jira/testdata/attachments.json new file mode 100644 index 00000000..6cc3f3db --- /dev/null +++ b/pkg/jira/testdata/attachments.json @@ -0,0 +1,31 @@ +{ + "key": "TEST-1", + "fields": { + "attachment": [ + { + "id": "10001", + "filename": "screenshot.png", + "mimeType": "image/png", + "size": 12345, + "content": "http://localhost/attachment/content/10001", + "created": "2024-01-15T10:30:00.000+0000", + "author": { + "accountId": "abc123", + "displayName": "Test User" + } + }, + { + "id": "10002", + "filename": "document.pdf", + "mimeType": "application/pdf", + "size": 54321, + "content": "http://localhost/attachment/content/10002", + "created": "2024-01-16T14:20:00.000+0000", + "author": { + "accountId": "def456", + "displayName": "Another User" + } + } + ] + } +} diff --git a/pkg/jira/types.go b/pkg/jira/types.go index e1c17719..c18fe6ab 100644 --- a/pkg/jira/types.go +++ b/pkg/jira/types.go @@ -122,8 +122,9 @@ type IssueFields struct { InwardIssue *Issue `json:"inwardIssue,omitempty"` OutwardIssue *Issue `json:"outwardIssue,omitempty"` } `json:"issueLinks"` - Created string `json:"created"` - Updated string `json:"updated"` + Created string `json:"created"` + Updated string `json:"updated"` + Attachment []Attachment `json:"attachment"` } // Field holds field info. @@ -165,6 +166,17 @@ type IssueLinkType struct { Outward string `json:"outward"` } +// Attachment holds attachment info. +type Attachment struct { + ID string `json:"id"` + Filename string `json:"filename"` + MimeType string `json:"mimeType"` + Size int `json:"size"` + Content string `json:"content"` + Created string `json:"created"` + Author User `json:"author"` +} + // Sprint holds sprint info. type Sprint struct { ID int `json:"id"`