diff --git a/api/attachments.go b/api/attachments.go new file mode 100644 index 0000000..f49df13 --- /dev/null +++ b/api/attachments.go @@ -0,0 +1,272 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +// Attachment represents a Jira attachment +type Attachment struct { + ID FlexibleID `json:"id"` + Filename string `json:"filename"` + Author User `json:"author"` + Created string `json:"created"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + Content string `json:"content"` // URL to download the attachment + Self string `json:"self"` +} + +// FlexibleID handles Jira API inconsistency where IDs can be strings or numbers +type FlexibleID string + +// UnmarshalJSON handles both string and number JSON values for IDs +func (f *FlexibleID) UnmarshalJSON(data []byte) error { + // Try string first + var s string + if err := json.Unmarshal(data, &s); err == nil { + *f = FlexibleID(s) + return nil + } + + // Try number + var n int64 + if err := json.Unmarshal(data, &n); err == nil { + *f = FlexibleID(fmt.Sprintf("%d", n)) + return nil + } + + return fmt.Errorf("id must be string or number, got: %s", string(data)) +} + +// String returns the ID as a string +func (f FlexibleID) String() string { + return string(f) +} + +// GetIssueAttachments returns all attachments for an issue +func (c *Client) GetIssueAttachments(issueKey string) ([]Attachment, error) { + if issueKey == "" { + return nil, fmt.Errorf("issue key is required") + } + + urlStr := fmt.Sprintf("%s/issue/%s?fields=attachment", c.BaseURL, issueKey) + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result struct { + Fields struct { + Attachment []Attachment `json:"attachment"` + } `json:"fields"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse attachments: %w", err) + } + + return result.Fields.Attachment, nil +} + +// GetAttachment returns metadata for a specific attachment +func (c *Client) GetAttachment(attachmentID string) (*Attachment, error) { + if attachmentID == "" { + return nil, fmt.Errorf("attachment ID is required") + } + + urlStr := fmt.Sprintf("%s/attachment/%s", c.BaseURL, attachmentID) + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var attachment Attachment + if err := json.Unmarshal(body, &attachment); err != nil { + return nil, fmt.Errorf("failed to parse attachment: %w", err) + } + + return &attachment, nil +} + +// AddAttachment uploads a file as an attachment to an issue +func (c *Client) AddAttachment(issueKey, filePath string) ([]Attachment, error) { + if issueKey == "" { + return nil, fmt.Errorf("issue key is required") + } + if filePath == "" { + return nil, fmt.Errorf("file path is required") + } + + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Create multipart form + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + + // Write the file in a goroutine to avoid blocking + errChan := make(chan error, 1) + go func() { + defer pw.Close() + defer writer.Close() + + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + errChan <- fmt.Errorf("failed to create form file: %w", err) + return + } + + if _, err := io.Copy(part, file); err != nil { + errChan <- fmt.Errorf("failed to copy file content: %w", err) + return + } + errChan <- nil + }() + + urlStr := fmt.Sprintf("%s/issue/%s/attachments", c.BaseURL, issueKey) + + req, err := http.NewRequest(http.MethodPost, urlStr, pr) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", c.authHeader()) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Accept", "application/json") + // Required header for attachment uploads + req.Header.Set("X-Atlassian-Token", "no-check") + + if c.Verbose { + fmt.Printf("→ POST %s\n", urlStr) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Wait for the write goroutine to finish + if writeErr := <-errChan; writeErr != nil { + return nil, writeErr + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if c.Verbose { + fmt.Printf("← %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + if resp.StatusCode >= 400 { + return nil, ParseAPIError(resp, respBody) + } + + var attachments []Attachment + if err := json.Unmarshal(respBody, &attachments); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return attachments, nil +} + +// DeleteAttachment deletes an attachment by ID +func (c *Client) DeleteAttachment(attachmentID string) error { + if attachmentID == "" { + return fmt.Errorf("attachment ID is required") + } + + urlStr := fmt.Sprintf("%s/attachment/%s", c.BaseURL, attachmentID) + _, err := c.delete(urlStr) + return err +} + +// DownloadAttachment downloads an attachment to the specified output path +func (c *Client) DownloadAttachment(attachment *Attachment, outputPath string) error { + if attachment == nil { + return fmt.Errorf("attachment is required") + } + if attachment.Content == "" { + return fmt.Errorf("attachment has no content URL") + } + + // Create the request + req, err := http.NewRequest(http.MethodGet, attachment.Content, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", c.authHeader()) + + if c.Verbose { + fmt.Printf("→ GET %s\n", attachment.Content) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if c.Verbose { + fmt.Printf("← %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return ParseAPIError(resp, body) + } + + // Determine output file path + outFile := outputPath + if isDirectory(outputPath) { + outFile = filepath.Join(outputPath, attachment.Filename) + } + + // Create the output file + file, err := os.Create(outFile) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + + // Copy the content + if _, err := io.Copy(file, resp.Body); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// isDirectory checks if a path is a directory +func isDirectory(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +// FormatFileSize returns a human-readable file size +func FormatFileSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/cmd/jtk/main.go b/cmd/jtk/main.go index cce332b..7962521 100644 --- a/cmd/jtk/main.go +++ b/cmd/jtk/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/attachments" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/boards" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/comments" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/completion" @@ -32,6 +33,7 @@ func run() error { issues.Register(rootCmd, opts) transitions.Register(rootCmd, opts) comments.Register(rootCmd, opts) + attachments.Register(rootCmd, opts) boards.Register(rootCmd, opts) sprints.Register(rootCmd, opts) users.Register(rootCmd, opts) diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..85860dc --- /dev/null +++ b/integration/README.md @@ -0,0 +1,86 @@ +# Integration Tests + +These tests run against real Jira APIs and require valid credentials. + +## Prerequisites + +Set the following environment variables: + +```bash +export JIRA_URL="https://your-domain.atlassian.net" +export JIRA_EMAIL="your-email@example.com" +export JIRA_API_TOKEN="your-api-token" + +# For tests that create issues, you need a project key where you have permission +export JIRA_TEST_PROJECT="TEST" + +# Optional: specify the issue type to use (default: "Task") +# Some projects may use different names like "SDLC", "Story", "Bug", etc. +export JIRA_TEST_ISSUE_TYPE="Task" +``` + +Alternatively, use `ATLASSIAN_URL`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` if you have those configured. + +## Running Tests + +```bash +# Run all integration tests +go test -tags=integration ./integration/... + +# Run specific test file +go test -tags=integration ./integration/... -run TestAttachments + +# Run with verbose output +go test -tags=integration -v ./integration/... + +# Run a single test +go test -tags=integration -v ./integration/... -run TestAttachments_FullFlow +``` + +## When to Run + +Run integration tests when: + +- Modifying API client code in `api/` +- Adding new API functionality +- Changing authentication or request handling +- Debugging production issues +- Before releasing a new version + +## Test Behavior + +- Tests skip automatically if credentials are not configured +- Tests clean up after themselves (delete created issues, attachments, etc.) +- Tests use unique identifiers to avoid conflicts with parallel runs +- Some tests may be skipped if the Jira instance doesn't support certain features + +## Adding New Tests + +1. Create a new file with `_test.go` suffix +2. Add the `//go:build integration` build tag at the top +3. Use `skipIfNoCredentials(t)` at the start of each test +4. Clean up any created resources in a `defer` or `t.Cleanup()` + +Example: + +```go +//go:build integration + +package integration + +func TestMyFeature(t *testing.T) { + skipIfNoCredentials(t) + client := newTestClient(t) + + // Create test data + // ... + + // Clean up + t.Cleanup(func() { + // Delete created resources + }) + + // Test assertions + // ... +} +``` diff --git a/integration/attachments_test.go b/integration/attachments_test.go new file mode 100644 index 0000000..d19c6f6 --- /dev/null +++ b/integration/attachments_test.go @@ -0,0 +1,182 @@ +//go:build integration + +package integration + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAttachments_FullFlow(t *testing.T) { + skipIfNoCredentials(t) + project := getTestProject(t) + client := newTestClient(t) + + // Create a test issue to attach files to + issueKey := createTestIssue(t, client, project, "[Integration Test] Attachment test - safe to delete") + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test-attachment.txt") + testContent := []byte("Integration test attachment content") + require.NoError(t, os.WriteFile(testFile, testContent, 0644)) + + // Test 1: Add attachment + t.Run("AddAttachment", func(t *testing.T) { + attachments, err := client.AddAttachment(issueKey, testFile) + require.NoError(t, err, "AddAttachment should succeed") + require.Len(t, attachments, 1, "Should return one attachment") + + att := attachments[0] + assert.Equal(t, "test-attachment.txt", att.Filename) + assert.Equal(t, int64(len(testContent)), att.Size) + assert.NotEmpty(t, att.ID.String(), "Attachment ID should not be empty") + }) + + // Test 2: List attachments + var attachmentID string + t.Run("ListAttachments", func(t *testing.T) { + attachments, err := client.GetIssueAttachments(issueKey) + require.NoError(t, err, "GetIssueAttachments should succeed") + require.NotEmpty(t, attachments, "Issue should have attachments") + + // Find our attachment + found := false + for _, att := range attachments { + if att.Filename == "test-attachment.txt" { + found = true + attachmentID = att.ID.String() + assert.Equal(t, int64(len(testContent)), att.Size) + break + } + } + assert.True(t, found, "Should find the uploaded attachment") + }) + + // Test 3: Get attachment metadata + t.Run("GetAttachment", func(t *testing.T) { + require.NotEmpty(t, attachmentID, "Need attachment ID from previous test") + + att, err := client.GetAttachment(attachmentID) + require.NoError(t, err, "GetAttachment should succeed") + + assert.Equal(t, "test-attachment.txt", att.Filename) + assert.Equal(t, int64(len(testContent)), att.Size) + assert.NotEmpty(t, att.Content, "Should have content URL") + }) + + // Test 4: Download attachment + t.Run("DownloadAttachment", func(t *testing.T) { + require.NotEmpty(t, attachmentID, "Need attachment ID from previous test") + + att, err := client.GetAttachment(attachmentID) + require.NoError(t, err) + + downloadPath := filepath.Join(tmpDir, "downloaded.txt") + err = client.DownloadAttachment(att, downloadPath) + require.NoError(t, err, "DownloadAttachment should succeed") + + // Verify downloaded content + downloaded, err := os.ReadFile(downloadPath) + require.NoError(t, err) + assert.Equal(t, testContent, downloaded, "Downloaded content should match original") + }) + + // Test 5: Download to directory (uses original filename) + t.Run("DownloadToDirectory", func(t *testing.T) { + require.NotEmpty(t, attachmentID, "Need attachment ID from previous test") + + att, err := client.GetAttachment(attachmentID) + require.NoError(t, err) + + downloadDir := filepath.Join(tmpDir, "downloads") + require.NoError(t, os.MkdirAll(downloadDir, 0755)) + + err = client.DownloadAttachment(att, downloadDir) + require.NoError(t, err, "DownloadAttachment to directory should succeed") + + // Verify file was created with original filename + downloadedPath := filepath.Join(downloadDir, "test-attachment.txt") + downloaded, err := os.ReadFile(downloadedPath) + require.NoError(t, err) + assert.Equal(t, testContent, downloaded) + }) + + // Test 6: Delete attachment + t.Run("DeleteAttachment", func(t *testing.T) { + require.NotEmpty(t, attachmentID, "Need attachment ID from previous test") + + err := client.DeleteAttachment(attachmentID) + require.NoError(t, err, "DeleteAttachment should succeed") + + // Verify deletion + attachments, err := client.GetIssueAttachments(issueKey) + require.NoError(t, err) + + for _, att := range attachments { + assert.NotEqual(t, attachmentID, att.ID.String(), "Attachment should be deleted") + } + }) +} + +func TestAttachments_MultipleFiles(t *testing.T) { + skipIfNoCredentials(t) + project := getTestProject(t) + client := newTestClient(t) + + issueKey := createTestIssue(t, client, project, "[Integration Test] Multiple attachments - safe to delete") + + tmpDir := t.TempDir() + + // Create multiple test files + files := []string{"file1.txt", "file2.txt", "file3.txt"} + for _, name := range files { + path := filepath.Join(tmpDir, name) + require.NoError(t, os.WriteFile(path, []byte("Content of "+name), 0644)) + + _, err := client.AddAttachment(issueKey, path) + require.NoError(t, err, "Should add attachment %s", name) + } + + // Verify all attachments exist + attachments, err := client.GetIssueAttachments(issueKey) + require.NoError(t, err) + assert.Len(t, attachments, len(files), "Should have all attachments") + + // Clean up + for _, att := range attachments { + require.NoError(t, client.DeleteAttachment(att.ID.String())) + } +} + +func TestAttachments_ErrorCases(t *testing.T) { + skipIfNoCredentials(t) + client := newTestClient(t) + + t.Run("ListAttachments_InvalidIssue", func(t *testing.T) { + _, err := client.GetIssueAttachments("INVALID-99999") + assert.Error(t, err, "Should fail for invalid issue") + }) + + t.Run("GetAttachment_InvalidID", func(t *testing.T) { + _, err := client.GetAttachment("99999999") + assert.Error(t, err, "Should fail for invalid attachment ID") + }) + + t.Run("DeleteAttachment_InvalidID", func(t *testing.T) { + err := client.DeleteAttachment("99999999") + assert.Error(t, err, "Should fail for invalid attachment ID") + }) + + t.Run("AddAttachment_NonexistentFile", func(t *testing.T) { + project := getTestProject(t) + issueKey := createTestIssue(t, client, project, "[Integration Test] Error case - safe to delete") + + _, err := client.AddAttachment(issueKey, "/nonexistent/file.txt") + assert.Error(t, err, "Should fail for nonexistent file") + }) +} diff --git a/integration/helpers_test.go b/integration/helpers_test.go new file mode 100644 index 0000000..72d724a --- /dev/null +++ b/integration/helpers_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package integration + +import ( + "os" + "testing" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/config" +) + +// skipIfNoCredentials skips the test if Jira credentials are not configured +func skipIfNoCredentials(t *testing.T) { + t.Helper() + + url := config.GetURL() + email := config.GetEmail() + token := config.GetAPIToken() + + if url == "" || email == "" || token == "" { + t.Skip("Jira credentials not configured (set JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN or ATLASSIAN_* equivalents)") + } +} + +// getTestProject returns the project key for integration tests +func getTestProject(t *testing.T) string { + t.Helper() + + project := os.Getenv("JIRA_TEST_PROJECT") + if project == "" { + t.Skip("JIRA_TEST_PROJECT not set") + } + return project +} + +// getTestIssueType returns the issue type to use for integration tests +// Defaults to "Task" but can be overridden with JIRA_TEST_ISSUE_TYPE +func getTestIssueType(t *testing.T) string { + t.Helper() + + issueType := os.Getenv("JIRA_TEST_ISSUE_TYPE") + if issueType == "" { + issueType = "Task" + } + return issueType +} + +// newTestClient creates an API client for integration tests +func newTestClient(t *testing.T) *api.Client { + t.Helper() + + client, err := api.New(api.ClientConfig{ + URL: config.GetURL(), + Email: config.GetEmail(), + APIToken: config.GetAPIToken(), + }) + if err != nil { + t.Fatalf("Failed to create API client: %v", err) + } + + return client +} + +// createTestIssue creates a temporary issue for testing and returns its key +// The issue is automatically deleted when the test completes +func createTestIssue(t *testing.T, client *api.Client, project, summary string) string { + t.Helper() + + issueType := getTestIssueType(t) + req := api.BuildCreateRequest(project, issueType, summary, "", nil) + issue, err := client.CreateIssue(req) + if err != nil { + t.Fatalf("Failed to create test issue (project=%s, type=%s): %v", project, issueType, err) + } + + t.Cleanup(func() { + if err := client.DeleteIssue(issue.Key); err != nil { + t.Logf("Warning: failed to delete test issue %s: %v", issue.Key, err) + } + }) + + return issue.Key +} diff --git a/internal/cmd/attachments/attachments.go b/internal/cmd/attachments/attachments.go new file mode 100644 index 0000000..cd55b86 --- /dev/null +++ b/internal/cmd/attachments/attachments.go @@ -0,0 +1,262 @@ +package attachments + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +// Register registers the attachments commands +func Register(parent *cobra.Command, opts *root.Options) { + cmd := &cobra.Command{ + Use: "attachments", + Aliases: []string{"attachment", "att"}, + Short: "Manage issue attachments", + Long: "Commands for listing, adding, downloading, and deleting issue attachments.", + } + + cmd.AddCommand(newListCmd(opts)) + cmd.AddCommand(newAddCmd(opts)) + cmd.AddCommand(newGetCmd(opts)) + cmd.AddCommand(newDeleteCmd(opts)) + + parent.AddCommand(cmd) +} + +func newListCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Aliases: []string{"ls"}, + Short: "List attachments on an issue", + Long: "List all attachments on a Jira issue.", + Example: ` # List attachments + jtk attachments list PROJ-123 + + # Output as JSON + jtk attachments list PROJ-123 -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runList(opts, args[0]) + }, + } + + return cmd +} + +func runList(opts *root.Options, issueKey string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + attachments, err := client.GetIssueAttachments(issueKey) + if err != nil { + return err + } + + if len(attachments) == 0 { + v.Info("No attachments found on %s", issueKey) + return nil + } + + if opts.Output == "json" { + return v.JSON(attachments) + } + + headers := []string{"ID", "FILENAME", "SIZE", "AUTHOR", "CREATED"} + var rows [][]string + + for _, att := range attachments { + created := formatDate(att.Created) + author := att.Author.DisplayName + if author == "" { + author = att.Author.AccountID + } + + rows = append(rows, []string{ + att.ID.String(), + att.Filename, + api.FormatFileSize(att.Size), + author, + created, + }) + } + + return v.Table(headers, rows) +} + +func newAddCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "add --file ", + Short: "Add an attachment to an issue", + Long: `Upload a file as an attachment to a Jira issue. + +Multiple files can be attached by repeating the --file flag.`, + Example: ` # Add a single file + jtk attachments add PROJ-123 --file document.pdf + + # Add multiple files + jtk attachments add PROJ-123 --file doc.pdf --file screenshot.png`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + files, _ := cmd.Flags().GetStringArray("file") + return runAdd(opts, args[0], files) + }, + } + + cmd.Flags().StringArrayP("file", "f", nil, "File(s) to attach (can be repeated)") + cmd.MarkFlagRequired("file") //nolint:errcheck + + return cmd +} + +func runAdd(opts *root.Options, issueKey string, files []string) error { + v := opts.View() + + if len(files) == 0 { + return fmt.Errorf("at least one file is required") + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + var allAttachments []api.Attachment + for _, file := range files { + v.Info("Uploading %s...", filepath.Base(file)) + + attachments, err := client.AddAttachment(issueKey, file) + if err != nil { + return fmt.Errorf("failed to upload %s: %w", file, err) + } + + allAttachments = append(allAttachments, attachments...) + } + + if opts.Output == "json" { + return v.JSON(allAttachments) + } + + for _, att := range allAttachments { + v.Success("Added %s (ID: %s, %s)", att.Filename, att.ID.String(), api.FormatFileSize(att.Size)) + } + + return nil +} + +func newGetCmd(opts *root.Options) *cobra.Command { + var outputPath string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Download an attachment", + Long: `Download an attachment by its ID. + +The attachment ID can be found using 'jtk attachments list'.`, + Example: ` # Download to current directory + jtk attachments get 12345 + + # Download to specific directory + jtk attachments get 12345 --output ./downloads/ + + # Download with specific filename + jtk attachments get 12345 --output ./myfile.pdf`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGet(opts, args[0], outputPath) + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "O", ".", "Output path (directory or file)") + + return cmd +} + +func runGet(opts *root.Options, attachmentID, outputPath string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + // Get attachment metadata first + attachment, err := client.GetAttachment(attachmentID) + if err != nil { + return err + } + + v.Info("Downloading %s (%s)...", attachment.Filename, api.FormatFileSize(attachment.Size)) + + if err := client.DownloadAttachment(attachment, outputPath); err != nil { + return err + } + + // Determine final output path for success message + finalPath := outputPath + if isDirectory(outputPath) { + finalPath = filepath.Join(outputPath, attachment.Filename) + } + + v.Success("Downloaded to %s", finalPath) + return nil +} + +func newDeleteCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an attachment", + Long: `Delete an attachment by its ID. + +The attachment ID can be found using 'jtk attachments list'.`, + Example: ` # Delete an attachment + jtk attachments delete 12345`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(opts, args[0]) + }, + } + + return cmd +} + +func runDelete(opts *root.Options, attachmentID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.DeleteAttachment(attachmentID); err != nil { + return err + } + + v.Success("Deleted attachment %s", attachmentID) + return nil +} + +// formatDate extracts just the date portion from an ISO timestamp +func formatDate(timestamp string) string { + if len(timestamp) >= 10 { + return timestamp[:10] + } + return timestamp +} + +// isDirectory checks if a path is a directory +func isDirectory(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +}