Skip to content
Open
11 changes: 11 additions & 0 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
28 changes: 28 additions & 0 deletions internal/cmd/issue/attachment/attachment.go
Original file line number Diff line number Diff line change
@@ -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()
}
126 changes: 126 additions & 0 deletions internal/cmd/issue/attachment/download/download.go
Original file line number Diff line number Diff line change
@@ -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: ./<ISSUE-KEY>/)")

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)
}
}
3 changes: 2 additions & 1 deletion internal/cmd/issue/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions pkg/jira/attachment.go
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions pkg/jira/attachment_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading