Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 272 additions & 0 deletions api/attachments.go
Original file line number Diff line number Diff line change
@@ -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])
}
2 changes: 2 additions & 0 deletions cmd/jtk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions integration/README.md
Original file line number Diff line number Diff line change
@@ -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
// ...
}
```
Loading