Skip to content

Enterprise-grade Go client for Jira Cloud & Server/Data Center REST APIs. Features resilience patterns, environment config, zero-allocation logging, and comprehensive documentation. Production-ready with full context support.

License

Notifications You must be signed in to change notification settings

felixgeelhaar/jirasdk

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

67 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

jirasdk - Enterprise Jira Client for Go

Go Version Go Reference Go Report Card License

A production-grade, idiomatic Go client library for Jira Cloud and Server/Data Center REST APIs.

Features

  • βœ… Idiomatic Go - Follows Go best practices and conventions
  • βœ… Context Support - Full context propagation for cancellation and timeouts
  • βœ… Functional Options - Flexible, extensible configuration pattern
  • βœ… Automatic Retries - Exponential backoff with jitter
  • βœ… Rate Limiting - Automatic handling of rate limits
  • βœ… Type Safe - Strongly typed domain models
  • βœ… Middleware - Extensible request/response pipeline
  • βœ… Multiple Auth - OAuth 2.0, API Tokens, PAT, Basic Auth
  • βœ… Enterprise Ready - Production-grade error handling and logging
  • πŸš€ High Performance - 40-60% faster search, 30-50% faster expressions (v1.2.0+)

Installation

go get github.com/felixgeelhaar/jirasdk

Quick Start

Jira Cloud (API Token)

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    jira "github.com/felixgeelhaar/jirasdk"
)

func main() {
    // Create client
    client, err := jira.NewClient(
        jira.WithBaseURL("https://your-domain.atlassian.net"),
        jira.WithAPIToken("your-email@example.com", "your-api-token"),
        jira.WithTimeout(30*time.Second),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use client
    ctx := context.Background()
    issue, err := client.Issue.Get(ctx, "PROJ-123", nil)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Issue: %s - %s\n", issue.Key, issue.Fields.Summary)
}

Jira Server/Data Center (PAT)

client, err := jira.NewClient(
    jira.WithBaseURL("https://jira.your-company.com"),
    jira.WithPAT("your-personal-access-token"),
)

Type Usage Patterns & Best Practices

This SDK uses type-safe patterns and safe accessor methods to prevent common errors like nil pointer panics. Follow these patterns for robust, production-ready code.

βœ… Safe Accessor Methods (Recommended)

ALWAYS use safe accessor methods when reading issue data. These methods handle nil values gracefully and return sensible defaults:

// Get an issue
issue, err := client.Issue.Get(ctx, "PROJ-123", nil)
if err != nil {
    log.Fatal(err)
}

// βœ… SAFE - Use accessor methods (recommended)
summary := issue.GetSummary()                    // Returns string (empty if nil)
statusName := issue.GetStatusName()              // Returns string (empty if nil)
priorityName := issue.GetPriorityName()          // Returns string (empty if nil)
assigneeName := issue.GetAssigneeName()          // Returns string (empty if nil)
created := issue.GetCreatedTime()                // Returns time.Time (zero if nil)
fixVersions := issue.GetFixVersions()            // Returns []*Version (empty slice if nil)
parentKey := issue.GetParentKey()                // Returns string (empty if not subtask)

// ❌ UNSAFE - Direct field access (can panic!)
// summary := issue.Fields.Summary               // Panics if Fields is nil
// status := issue.Fields.Status.Name            // Panics if Status is nil
// created := *issue.Fields.Created              // Panics if Created is nil

πŸ“ Creating Issues - Required & Optional Fields

When creating issues, use the typed structs for required fields:

// Create a bug with required fields
newIssue, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        // Required fields
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "Critical login bug",
        IssueType: &issue.IssueType{Name: "Bug"},

        // Optional but commonly used
        Priority:  &issue.Priority{Name: "High"},
        Labels:    []string{"security", "urgent"},

        // Version fields (typically for bugs)
        AffectsVersions: []*project.Version{
            {Name: "1.0.0"},
        },
        FixVersions: []*project.Version{
            {Name: "1.1.0"},
        },
    },
})

Description and Environment use ADF (Atlassian Document Format):

// Simple text - Use convenience methods
fields := &issue.IssueFields{
    Project:   &issue.Project{Key: "PROJ"},
    Summary:   "Production issue",
    IssueType: &issue.IssueType{Name: "Bug"},
}
fields.SetDescriptionText("Users cannot log in after deployment")
fields.SetEnvironmentText("Production: server-01, Ubuntu 22.04")

// Rich formatting - Use ADF builder
adf := issue.NewADF().
    AddHeading("Problem", 2).
    AddParagraph("The authentication service fails when...").
    AddBulletList([]string{
        "Step 1: Navigate to login page",
        "Step 2: Enter credentials",
        "Step 3: Click submit",
    })
fields.SetDescription(adf)

πŸ”„ Updating Issues - Use map[string]interface{}

When updating, use lowercase field names (Jira API convention):

// Update multiple fields
err := client.Issue.Update(ctx, "PROJ-123", &issue.UpdateInput{
    Fields: map[string]interface{}{
        "summary":  "Updated summary",
        "priority": map[string]string{"name": "Critical"},
        "labels":   []string{"bug", "production"},

        // Version updates
        "fixVersions": []map[string]string{
            {"name": "2.0.0"},
        },

        // Resolution (when closing)
        "resolution": map[string]string{"name": "Done"},
    },
})

πŸ‘¨β€πŸ‘©β€πŸ‘§ Working with Subtasks

Create subtasks by setting IssueType to "Sub-task" and providing a Parent:

// Create subtask
subtask, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "Implement login API",
        IssueType: &issue.IssueType{Name: "Sub-task"},  // Critical!
        Parent:    &issue.IssueRef{Key: "PROJ-123"},    // Link to parent
    },
})

// Get subtask and access parent safely
retrieved, err := client.Issue.Get(ctx, subtask.Key, nil)
if parentKey := retrieved.GetParentKey(); parentKey != "" {
    fmt.Printf("Parent: %s\n", parentKey)
}

// Move subtask to different parent
err = client.Issue.Update(ctx, subtask.Key, &issue.UpdateInput{
    Fields: map[string]interface{}{
        "parent": map[string]string{"key": "PROJ-456"},
    },
})

// Find all subtasks of a parent using JQL
searchResult, err := client.Search.SearchJQL(ctx, &search.SearchJQLOptions{
    JQL:        "parent = PROJ-123",
    MaxResults: 50,
})

πŸ“¦ Version Management

Use FixVersions for release planning and AffectsVersions for bug tracking:

// Create bug with version tracking
bug, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "Authentication fails in production",
        IssueType: &issue.IssueType{Name: "Bug"},

        // Which versions have this bug
        AffectsVersions: []*project.Version{
            {Name: "1.0.0"},
            {Name: "1.1.0"},
        },

        // Which version will fix it
        FixVersions: []*project.Version{
            {Name: "1.2.0"},
        },
    },
})

// Read version information safely
retrieved, err := client.Issue.Get(ctx, bug.Key, &issue.GetOptions{
    Fields: []string{"versions", "fixVersions"},
})

// βœ… Safe - Use accessor methods
affectsVersions := retrieved.GetAffectsVersions()  // Never nil
fixVersions := retrieved.GetFixVersions()          // Never nil

for _, v := range affectsVersions {
    fmt.Printf("Affects: %s\n", v.Name)
}

❌ Common Mistakes to Avoid

// ❌ DON'T: Direct field access (can panic!)
summary := issue.Fields.Summary                    // Panics if Fields is nil
status := issue.Fields.Status.Name                 // Panics if Status is nil
created := *issue.Fields.Created                   // Panics if Created is nil
parent := issue.Fields.Parent.Key                  // Panics if Parent is nil

// βœ… DO: Use safe accessor methods
summary := issue.GetSummary()                      // Returns "" if nil
status := issue.GetStatusName()                    // Returns "" if nil
created := issue.GetCreatedTime()                  // Returns time.Time{} if nil
parent := issue.GetParentKey()                     // Returns "" if nil

// ❌ DON'T: Use uppercase field names in updates
err := client.Issue.Update(ctx, key, &issue.UpdateInput{
    Fields: map[string]interface{}{
        "Summary": "Wrong",                        // Wrong! Won't work
    },
})

// βœ… DO: Use lowercase field names (Jira API convention)
err := client.Issue.Update(ctx, key, &issue.UpdateInput{
    Fields: map[string]interface{}{
        "summary": "Correct",                      // Correct!
    },
})

// ❌ DON'T: Forget IssueType when creating subtasks
subtask, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Summary: "Subtask",
        Parent:  &issue.IssueRef{Key: "PROJ-123"}, // Missing IssueType!
    },
})

// βœ… DO: Always set IssueType to "Sub-task"
subtask, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Summary:   "Subtask",
        IssueType: &issue.IssueType{Name: "Sub-task"},  // Required!
        Parent:    &issue.IssueRef{Key: "PROJ-123"},
    },
})

πŸ“š More Examples

See the examples directory for complete, runnable examples:

  • Basic Usage - Creating, reading, updating issues
  • Subtasks - Parent/child relationships, moving subtasks, JQL queries
  • Versions - Version management, affected/fix versions, resolutions
  • Custom Fields - Working with custom fields
  • Comments - Adding and managing comments
  • Workflow - Issue transitions and workflow states

⚠️ Migration Notice (v1.2.0)

Enhanced JQL Service API - We've introduced improved methods with better performance:

  • Search: SearchJQL() replaces Search() (40-60% faster pagination)
    • Search() deprecated, will be removed October 31, 2025
  • Expressions: EvaluateExpression() replaces Evaluate() (30-50% faster)
    • Evaluate() deprecated, will be removed August 1, 2025

πŸ“– See MIGRATION_GUIDE.md for detailed migration instructions and code examples.

Configuration Options

Environment Variables (Recommended)

Configure your client automatically from environment variables, following AWS SDK and Azure SDK patterns:

# Jira Cloud (API Token)
export JIRA_BASE_URL="https://your-domain.atlassian.net"
export JIRA_EMAIL="user@example.com"
export JIRA_API_TOKEN="your-api-token"

# Jira Server/Data Center (PAT)
export JIRA_BASE_URL="https://jira.company.com"
export JIRA_PAT="your-personal-access-token"

# Optional configuration
export JIRA_TIMEOUT="60"              # Timeout in seconds (default: 30)
export JIRA_MAX_RETRIES="5"           # Max retries (default: 3)
export JIRA_RATE_LIMIT_BUFFER="10"    # Buffer in seconds (default: 5)
export JIRA_USER_AGENT="MyApp/1.0.0"  # Custom user agent

Then create your client with one line:

// Automatic configuration from environment
client, err := jira.LoadConfigFromEnv()

// Or combine with other options
client, err := jira.NewClient(
    jira.WithEnv(),                    // Load from environment
    jira.WithTimeout(90*time.Second),  // Override specific settings
)

Supported Environment Variables:

Variable Description Required
JIRA_BASE_URL Jira instance URL βœ… Yes
JIRA_EMAIL Email for API token auth With JIRA_API_TOKEN
JIRA_API_TOKEN API token (Jira Cloud) With JIRA_EMAIL
JIRA_PAT Personal Access Token (Server/DC) Alternative to API token
JIRA_USERNAME Username for basic auth With JIRA_PASSWORD
JIRA_PASSWORD Password for basic auth With JIRA_USERNAME
JIRA_OAUTH_CLIENT_ID OAuth client ID With OAuth secrets
JIRA_OAUTH_CLIENT_SECRET OAuth client secret With OAuth ID
JIRA_OAUTH_REDIRECT_URL OAuth redirect URL With OAuth credentials
JIRA_TIMEOUT HTTP timeout in seconds No (default: 30)
JIRA_MAX_RETRIES Maximum retry attempts No (default: 3)
JIRA_RATE_LIMIT_BUFFER Rate limit buffer seconds No (default: 5)
JIRA_USER_AGENT Custom user agent string No

Authentication (Programmatic)

// API Token (Jira Cloud - Recommended)
jira.WithAPIToken("email@example.com", "token")

// Personal Access Token (Server/Data Center - Recommended)
jira.WithPAT("token")

// Basic Auth (Legacy)
jira.WithBasicAuth("username", "password")

HTTP Client Configuration

// Timeout
jira.WithTimeout(60 * time.Second)

// Custom HTTP Client
jira.WithHTTPClient(&http.Client{
    Timeout: 60 * time.Second,
    Transport: customTransport,
})

// User Agent
jira.WithUserAgent("MyApp/1.0.0")

Retry and Rate Limiting

// Max retries
jira.WithMaxRetries(5)

// Rate limit buffer
jira.WithRateLimitBuffer(10 * time.Second)

OAuth 2.0 Authentication

// Create OAuth 2.0 authenticator
oauth := auth.NewOAuth2Authenticator(&auth.OAuth2Config{
    ClientID:     "your-client-id",
    ClientSecret: "your-client-secret",
    RedirectURL:  "http://localhost:8080/callback",
    Scopes:       []string{"read:jira-work", "write:jira-work"},
})

// Get authorization URL
authURL := oauth.GetAuthURL("state-string")
fmt.Println("Visit:", authURL)

// Exchange authorization code for token
token, err := oauth.Exchange(ctx, authorizationCode)

// Create client with OAuth 2.0
client, err := jira.NewClient(
    jira.WithBaseURL("https://your-domain.atlassian.net"),
    jira.WithOAuth2(oauth),
)

// Token is automatically refreshed when expired

Custom Middleware

// Add logging middleware
loggingMiddleware := func(next transport.RoundTripFunc) transport.RoundTripFunc {
    return func(ctx context.Context, req *http.Request) (*http.Response, error) {
        log.Printf("Request: %s %s", req.Method, req.URL)
        resp, err := next(ctx, req)
        if resp != nil {
            log.Printf("Response: %d", resp.StatusCode)
        }
        return resp, err
    }
}

client, err := jira.NewClient(
    jira.WithBaseURL("https://your-domain.atlassian.net"),
    jira.WithAPIToken("email", "token"),
    jira.WithMiddleware(loggingMiddleware),
)

API Coverage

Issues

// Get issue with specific fields
issue, err := client.Issue.Get(ctx, "PROJ-123", &issue.GetOptions{
    Fields: []string{"summary", "status", "assignee", "priority"},
})

// Create issue
input := &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "New issue",
        IssueType: &issue.IssueType{Name: "Task"},
        Priority:  &issue.Priority{Name: "High"},
        Labels:    []string{"bug", "urgent"},
    },
}
created, err := client.Issue.Create(ctx, input)

// Update issue
updateInput := &issue.UpdateInput{
    Fields: map[string]interface{}{
        "summary": "Updated summary",
        "priority": map[string]string{"name": "Medium"},
    },
}
err = client.Issue.Update(ctx, "PROJ-123", updateInput)

// Delete issue
err = client.Issue.Delete(ctx, "PROJ-123")

// Create subtask with parent
subtask, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "Implement API endpoint",
        IssueType: &issue.IssueType{Name: "Sub-task"},
        Parent:    &issue.IssueRef{Key: "PROJ-123"}, // Parent issue
    },
})

// Get subtask and access parent information
retrieved, err := client.Issue.Get(ctx, subtask.Key, nil)
if parentKey := retrieved.GetParentKey(); parentKey != "" {
    fmt.Printf("Parent: %s\n", parentKey)
}

// Move subtask to different parent
err = client.Issue.Update(ctx, subtask.Key, &issue.UpdateInput{
    Fields: map[string]interface{}{
        "parent": map[string]string{"key": "PROJ-456"},
    },
})

// Version management - track affected and fix versions
// Create bug with affected versions
bug, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "Critical bug in authentication",
        IssueType: &issue.IssueType{Name: "Bug"},
        AffectsVersions: []*project.Version{
            {Name: "1.0.0"},
            {Name: "1.1.0"},
        },
        FixVersions: []*project.Version{
            {Name: "1.2.0"},
        },
    },
})

// Get issue and safely access version information
retrieved, err := client.Issue.Get(ctx, bug.Key, &issue.GetOptions{
    Fields: []string{"versions", "fixVersions"},
})
affectsVersions := retrieved.GetAffectsVersions()
fixVersions := retrieved.GetFixVersions()
for _, version := range affectsVersions {
    fmt.Printf("Affects: %s\n", version.Name)
}
for _, version := range fixVersions {
    fmt.Printf("Fixed in: %s\n", version.Name)
}

// Update issue to add/change versions
err = client.Issue.Update(ctx, "PROJ-123", &issue.UpdateInput{
    Fields: map[string]interface{}{
        "fixVersions": []map[string]string{
            {"name": "2.0.0"},
        },
    },
})

// Resolution management - mark how issues were closed
// Set resolution when closing an issue
err = client.Issue.Update(ctx, "PROJ-123", &issue.UpdateInput{
    Fields: map[string]interface{}{
        "resolution": map[string]string{
            "name": "Done",
        },
    },
})

// Get issue and safely access resolution
retrieved, err = client.Issue.Get(ctx, "PROJ-123", &issue.GetOptions{
    Fields: []string{"resolution"},
})
if resolutionName := retrieved.GetResolutionName(); resolutionName != "" {
    fmt.Printf("Resolution: %s\n", resolutionName)
}

// List available resolutions
resolutions, err := client.Resolution.List(ctx)
for _, res := range resolutions {
    fmt.Printf("%s (ID: %s)\n", res.Name, res.ID)
}

// Transition issue
transitionInput := &issue.TransitionInput{
    Transition: &issue.Transition{ID: "11"},
}
err = client.Issue.DoTransition(ctx, "PROJ-123", transitionInput)

// Assign issue
err = client.Issue.Assign(ctx, "PROJ-123", "accountId")

// Comments
comment, err := client.Issue.AddComment(ctx, "PROJ-123", &issue.AddCommentInput{
    Body: "This is a comment",
})
comments, err := client.Issue.ListComments(ctx, "PROJ-123")

// Watchers and Voters
watchers, err := client.Issue.GetWatchers(ctx, "PROJ-123")
err = client.Issue.AddWatcher(ctx, "PROJ-123", "accountId")
votes, err := client.Issue.GetVotes(ctx, "PROJ-123")
err = client.Issue.AddVote(ctx, "PROJ-123")

// Attachments
file, _ := os.Open("document.pdf")
defer file.Close()
attachments, err := client.Issue.AddAttachment(ctx, "PROJ-123", &issue.AttachmentMetadata{
    Filename: "document.pdf",
    Content:  file,
})

// Upload from string/bytes
report := strings.NewReader("Report content here")
attachments, err = client.Issue.AddAttachment(ctx, "PROJ-123", &issue.AttachmentMetadata{
    Filename: "report.txt",
    Content:  report,
})

// Get attachment metadata
metadata, err := client.Issue.GetAttachment(ctx, "10000")
fmt.Printf("File: %s, Size: %d bytes\n", metadata.Filename, metadata.Size)

// Download attachment
content, err := client.Issue.DownloadAttachment(ctx, "10000")
defer content.Close()
data, _ := io.ReadAll(content)

// Delete attachment
err = client.Issue.DeleteAttachment(ctx, "10000")

// Issue Links
// Create a "blocks" relationship
err = client.Issue.CreateIssueLink(ctx, &issue.CreateIssueLinkInput{
    Type:         issue.BlocksLinkType(),
    InwardIssue:  &issue.IssueRef{Key: "PROJ-123"},
    OutwardIssue: &issue.IssueRef{Key: "PROJ-456"},
    Comment: &issue.LinkComment{
        Body: "These issues are related",
    },
})

// List available link types
linkTypes, err := client.Issue.ListIssueLinkTypes(ctx)

// Get links for an issue
links, err := client.Issue.GetIssueLinks(ctx, "PROJ-123")

// Delete issue link
err = client.Issue.DeleteIssueLink(ctx, "10000")

// Available helper functions:
// - issue.BlocksLinkType() - "blocks" / "is blocked by"
// - issue.DuplicatesLinkType() - "duplicates" / "is duplicated by"
// - issue.RelatesToLinkType() - "relates to" / "relates to"
// - issue.CausesLinkType() - "causes" / "is caused by"
// - issue.ClonesLinkType() - "clones" / "is cloned by"

// Time Tracking / Worklogs
now := time.Now()

// Add worklog with time string
worklog, err := client.Issue.AddWorklog(ctx, "PROJ-123", &issue.AddWorklogInput{
    TimeSpent: "3h 20m",
    Started:   &now,
    Comment:   "Implemented feature",
})

// Add worklog with seconds
worklog, err = client.Issue.AddWorklog(ctx, "PROJ-123", &issue.AddWorklogInput{
    TimeSpentSeconds: 7200, // 2 hours
    Started:          &now,
    Comment:          "Code review",
})

// List worklogs
worklogs, err := client.Issue.ListWorklogs(ctx, "PROJ-123", nil)

// List with date filters
yesterday := time.Now().AddDate(0, 0, -1)
worklogs, err = client.Issue.ListWorklogs(ctx, "PROJ-123", &issue.ListWorklogsOptions{
    StartedAfter: &yesterday,
    MaxResults:   10,
})

// Get specific worklog
worklog, err = client.Issue.GetWorklog(ctx, "PROJ-123", "10000")

// Update worklog
worklog, err = client.Issue.UpdateWorklog(ctx, "PROJ-123", "10000", &issue.UpdateWorklogInput{
    TimeSpent: "4h",
    Comment:   "Updated estimate",
})

// Delete worklog
err = client.Issue.DeleteWorklog(ctx, "PROJ-123", "10000")

// Format duration helper
formatted := issue.FormatDuration(12000) // Returns "3h 20m"

// Custom Fields
customFields := issue.NewCustomFields().
    SetString("customfield_10001", "Sprint 23").
    SetNumber("customfield_10002", 8.5).
    SetDate("customfield_10003", time.Now()).
    SetSelect("customfield_10004", "High").
    SetMultiSelect("customfield_10005", []string{"Backend", "API"}).
    SetLabels("customfield_10006", []string{"feature", "urgent"}).
    SetUser("customfield_10007", "accountId123")

// Create issue with custom fields
created, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "New issue",
        IssueType: &issue.IssueType{Name: "Task"},
        Custom:    customFields,
    },
})

// Read custom fields from an issue
retrieved, err := client.Issue.Get(ctx, "PROJ-123", nil)
if sprint, ok := retrieved.Fields.Custom.GetString("customfield_10001"); ok {
    fmt.Printf("Sprint: %s\n", sprint)
}
if storyPoints, ok := retrieved.Fields.Custom.GetNumber("customfield_10002"); ok {
    fmt.Printf("Story Points: %.1f\n", storyPoints)
}

// Update custom fields
updates := issue.NewCustomFields().
    SetString("customfield_10001", "Sprint 24")
err = client.Issue.Update(ctx, "PROJ-123", &issue.UpdateInput{
    Fields: updates.ToMap(),
})

// Date and Time Handling
// ⚠️ IMPORTANT: Always use safe accessor methods for date fields to avoid nil pointer panics
//
// πŸ“ Note: The SDK automatically handles Jira's various date/time formats:
//   - Date only: "2025-10-30"
//   - DateTime with timezone: "2024-01-01T10:30:00.000+0000" (non-standard Jira format)
//   - RFC3339: "2024-01-01T10:30:00.000Z"
//   - Time only: "15:30:00"
// This works transparently for both standard fields AND custom date/datetime fields!

// Reading standard date fields (Created, Updated, DueDate)
// βœ… SAFE: Use GetCreatedTime(), GetUpdatedTime(), GetDueDateValue()
if created := issue.GetCreatedTime(); !created.IsZero() {
    fmt.Printf("Created: %s\n", created.Format(time.RFC3339))
}

if updated := issue.GetUpdatedTime(); !updated.IsZero() {
    fmt.Printf("Updated: %s\n", updated.Format(time.RFC3339))
}

if dueDate := issue.GetDueDateValue(); !dueDate.IsZero() {
    fmt.Printf("Due Date: %s\n", dueDate.Format("2006-01-02"))
    // Check if overdue
    if time.Now().After(dueDate) {
        fmt.Println("Task is OVERDUE!")
    }
}

// Alternative: Use pointer accessors (GetDueDate() returns *time.Time)
if dueDatePtr := issue.GetDueDate(); dueDatePtr != nil {
    fmt.Printf("Due Date: %s\n", dueDatePtr.Format("2006-01-02"))
}

// ❌ DANGEROUS: NEVER directly access date fields (can cause nil pointer panic!)
// dueDate := issue.Fields.DueDate
// fmt.Println(dueDate.Format("2006-01-02"))  // PANIC if DueDate is nil!

// Setting DueDate when creating an issue
dueDate := time.Now().AddDate(0, 0, 14) // 14 days from now
created, err := client.Issue.Create(ctx, &issue.CreateInput{
    Fields: &issue.IssueFields{
        Project:   &issue.Project{Key: "PROJ"},
        Summary:   "Task with due date",
        IssueType: &issue.IssueType{Name: "Task"},
        DueDate:   &dueDate,
    },
})

// Updating DueDate (Jira expects YYYY-MM-DD format)
newDueDate := time.Now().AddDate(0, 0, 30) // 30 days from now
err = client.Issue.Update(ctx, "PROJ-123", &issue.UpdateInput{
    Fields: map[string]interface{}{
        "duedate": newDueDate.Format("2006-01-02"),
    },
})

// Clearing a DueDate
err = client.Issue.Update(ctx, "PROJ-123", &issue.UpdateInput{
    Fields: map[string]interface{}{
        "duedate": nil,
    },
})

// Custom date fields
// Date field (YYYY-MM-DD format)
customDate := time.Now().AddDate(0, 1, 0)
customFields := issue.NewCustomFields().
    SetDate("customfield_10001", customDate)

// DateTime field (RFC3339 format with timezone)
customDateTime := time.Now().AddDate(0, 0, 7)
customFields = issue.NewCustomFields().
    SetDateTime("customfield_10002", customDateTime)

// Read custom date fields
if customDate, ok := retrieved.Fields.Custom.GetDate("customfield_10001"); ok {
    fmt.Printf("Custom Date: %s\n", customDate.Format("2006-01-02"))
}

if customDateTime, ok := retrieved.Fields.Custom.GetDateTime("customfield_10002"); ok {
    fmt.Printf("Custom DateTime: %s\n", customDateTime.Format(time.RFC3339))
}

// See examples/dates for comprehensive date handling examples

// Workflows
// List all workflows
workflows, err := client.Workflow.List(ctx, &workflow.ListOptions{
    MaxResults: 50,
})

// Get specific workflow
workflow, err := client.Workflow.Get(ctx, "classic-default-workflow")

// Get available transitions for an issue
transitions, err := client.Workflow.GetTransitions(ctx, "PROJ-123", &workflow.GetTransitionsOptions{
    Expand: []string{"transitions.fields"},
})

// Get all statuses
statuses, err := client.Workflow.GetAllStatuses(ctx)

// Get specific status
status, err := client.Workflow.GetStatus(ctx, "10000")

// Workflow Schemes
// List all workflow schemes
schemes, err := client.Workflow.ListWorkflowSchemes(ctx, nil)

// Get specific workflow scheme
scheme, err := client.Workflow.GetWorkflowScheme(ctx, 10000)

// Check required fields for transition
for _, transition := range transitions {
    for fieldKey, field := range transition.Fields {
        if field.Required {
            fmt.Printf("Field %s is required\n", field.Name)
        }
    }
}

Search

// Modern JQL search (v1.2.0+) - 40-60% faster pagination
results, err := client.Search.SearchJQL(ctx, &search.SearchOptions{
    JQL:        "project = PROJ AND status = Open",
    MaxResults: 50,
})

// Query Builder
query := search.NewQueryBuilder().
    Project("PROJ").
    And().
    Status("In Progress").
    And().
    Assignee("currentUser()").
    OrderBy("created", "DESC")

results, err := client.Search.SearchJQL(ctx, &search.SearchOptions{
    JQL:        query.Build(),
    MaxResults: 100,
    Fields:     []string{"summary", "status", "priority"},
})

// Pagination
for i := 0; i < results.Total; i += 50 {
    page, err := client.Search.SearchJQL(ctx, &search.SearchOptions{
        JQL:        "project = PROJ",
        MaxResults: 50,
        StartAt:    i,
    })
}

// Legacy Search() method (deprecated, will be removed Oct 31, 2025)
// Use SearchJQL() instead for better performance and clearer intent
results, err := client.Search.Search(ctx, &search.SearchOptions{
    JQL: "project = PROJ",
})

Projects

// Get project
proj, err := client.Project.Get(ctx, "PROJ")

// List projects
projects, err := client.Project.List(ctx, &project.ListOptions{
    Recent: 10,
})

// Create project
newProject, err := client.Project.Create(ctx, &project.CreateInput{
    Key:            "DEMO",
    Name:           "Demo Project",
    ProjectTypeKey: "software",
    LeadAccountID:  "accountId123",
})

// Update project
_, err = client.Project.Update(ctx, "PROJ", &project.UpdateInput{
    Name:        "Updated Name",
    Description: "Updated description",
})

// Archive and restore
err = client.Project.Archive(ctx, "PROJ")
err = client.Project.Restore(ctx, "PROJ")

// Delete project
err = client.Project.Delete(ctx, "PROJ")

// Component Management
// List components
components, err := client.Project.ListProjectComponents(ctx, "PROJ")

// Create component
component, err := client.Project.CreateComponent(ctx, &project.CreateComponentInput{
    Name:         "Backend Services",
    Description:  "All backend microservices",
    Project:      "PROJ",
    AssigneeType: "PROJECT_DEFAULT",
})

// Update component
component, err = client.Project.UpdateComponent(ctx, "10000", &project.UpdateComponentInput{
    Description: "Updated description",
})

// Get component
component, err = client.Project.GetComponent(ctx, "10000")

// Delete component
err = client.Project.DeleteComponent(ctx, "10000")

// Version Management
// List versions
versions, err := client.Project.ListProjectVersions(ctx, "PROJ")

// Create version
version, err := client.Project.CreateVersion(ctx, &project.CreateVersionInput{
    Name:        "v1.0.0",
    Description: "First release",
    Project:     "PROJ",
    StartDate:   "2024-01-01",
    ReleaseDate: "2024-06-30",
    Released:    false,
})

// Update version (mark as released)
released := true
version, err = client.Project.UpdateVersion(ctx, "10000", &project.UpdateVersionInput{
    Released: &released,
})

// Get version
version, err = client.Project.GetVersion(ctx, "10000")

// Delete version
err = client.Project.DeleteVersion(ctx, "10000")

Users

// Get current user
user, err := client.User.GetMyself(ctx)

// Get user by account ID
user, err := client.User.Get(ctx, "accountId", &user.GetOptions{
    Expand: []string{"groups", "applicationRoles"},
})

// Search users
users, err := client.User.Search(ctx, &user.SearchOptions{
    Query:      "john",
    MaxResults: 50,
})

// Find assignable users for project
users, err := client.User.FindAssignableUsers(ctx, &user.FindAssignableOptions{
    Project: "PROJ",
    Query:   "smith",
})

// Bulk get users
users, err := client.User.BulkGet(ctx, &user.BulkGetOptions{
    AccountIDs: []string{"id1", "id2", "id3"},
})

Agile/Scrum

// Boards
// List all boards
boards, err := client.Agile.GetBoards(ctx, &agile.BoardsOptions{
    Type:       "scrum",  // or "kanban"
    MaxResults: 50,
})

// Get specific board
board, err := client.Agile.GetBoard(ctx, 123)

// Create board
newBoard, err := client.Agile.CreateBoard(ctx, &agile.CreateBoardInput{
    Name:     "Sprint Board",
    Type:     "scrum",
    FilterID: 10000,
})

// Delete board
err = client.Agile.DeleteBoard(ctx, 123)

// Sprints
// List board sprints
sprints, err := client.Agile.GetBoardSprints(ctx, 123, &agile.SprintsOptions{
    State:      "active,future",
    MaxResults: 50,
})

// Get specific sprint
sprint, err := client.Agile.GetSprint(ctx, 456)

// Create sprint
newSprint, err := client.Agile.CreateSprint(ctx, &agile.CreateSprintInput{
    Name:          "Sprint 25",
    OriginBoardID: 123,
    StartDate:     "2024-06-01T09:00:00.000Z",
    EndDate:       "2024-06-14T17:00:00.000Z",
    Goal:          "Complete user authentication",
})

// Update sprint
sprint, err = client.Agile.UpdateSprint(ctx, 456, &agile.UpdateSprintInput{
    State: "active",
    Goal:  "Updated goal",
})

// Delete sprint
err = client.Agile.DeleteSprint(ctx, 456)

// Move issues to sprint
err = client.Agile.MoveIssuesToSprint(ctx, 456, &agile.MoveIssuesToSprintInput{
    Issues: []string{"PROJ-123", "PROJ-124"},
})

// Epics
// List board epics
epics, err := client.Agile.GetBoardEpics(ctx, 123, &agile.EpicsOptions{
    MaxResults: 50,
})

// Get specific epic
epic, err := client.Agile.GetEpic(ctx, 789)

// Backlog
// Get backlog issues
backlog, err := client.Agile.GetBacklog(ctx, 123, &agile.BoardsOptions{
    MaxResults: 50,
})

Permissions

// Get all available permissions
allPermissions, err := client.Permission.GetAllPermissions(ctx)

// Check current user's permissions
myPerms, err := client.Permission.GetMyPermissions(ctx, nil)

// Check permissions for a specific project
projectPerms, err := client.Permission.GetMyPermissions(ctx, &permission.MyPermissionsOptions{
    ProjectKey:  "PROJ",
    Permissions: "BROWSE_PROJECTS,CREATE_ISSUES,EDIT_ISSUES",
})

// Permission Schemes
// List all permission schemes
schemes, err := client.Permission.ListPermissionSchemes(ctx, nil)

// Get detailed scheme with expanded information
scheme, err := client.Permission.GetPermissionScheme(ctx, 10000, &permission.GetPermissionSchemeOptions{
    Expand: []string{"permissions", "user", "group", "projectRole"},
})

// Create new permission scheme
newScheme, err := client.Permission.CreatePermissionScheme(ctx, &permission.CreatePermissionSchemeInput{
    Name:        "Custom Scheme",
    Description: "Custom permission scheme for development teams",
})

// Update permission scheme
updatedScheme, err := client.Permission.UpdatePermissionScheme(ctx, 10000, &permission.UpdatePermissionSchemeInput{
    Name:        "Updated Scheme Name",
    Description: "Updated description",
})

// Delete permission scheme
err = client.Permission.DeletePermissionScheme(ctx, 10000)

// Project Roles
// Get all roles for a project
roles, err := client.Permission.GetProjectRoles(ctx, "PROJ")

// Get specific role details
roleDetails, err := client.Permission.GetProjectRole(ctx, "PROJ", 10002)

// Add users to a project role
updatedRole, err := client.Permission.AddActorsToProjectRole(ctx, "PROJ", 10002, &permission.AddActorInput{
    User: []string{"accountId1", "accountId2"},
})

// Add groups to a project role
updatedRole, err = client.Permission.AddActorsToProjectRole(ctx, "PROJ", 10002, &permission.AddActorInput{
    Group: []string{"developers", "testers"},
})

// Remove actor from project role
err = client.Permission.RemoveActorFromProjectRole(ctx, "PROJ", 10002, "user", "accountId123")
err = client.Permission.RemoveActorFromProjectRole(ctx, "PROJ", 10002, "group", "developers")

Bulk Operations

// Bulk create issues (max 1000 per request)
result, err := client.Bulk.CreateIssues(ctx, &bulk.CreateIssuesInput{
    IssueUpdates: []bulk.IssueUpdate{
        {
            Fields: map[string]interface{}{
                "project":   map[string]string{"key": "PROJ"},
                "summary":   "Bulk created issue 1",
                "issuetype": map[string]string{"name": "Task"},
            },
        },
        {
            Fields: map[string]interface{}{
                "project":   map[string]string{"key": "PROJ"},
                "summary":   "Bulk created issue 2",
                "issuetype": map[string]string{"name": "Bug"},
                "labels":    []string{"bulk", "urgent"},
            },
        },
    },
})

// Check for errors
if len(result.Errors) > 0 {
    for _, err := range result.Errors {
        fmt.Printf("Error on element %d\n", err.FailedElementNumber)
    }
}

// Bulk delete issues (max 1000 per request)
err = client.Bulk.DeleteIssues(ctx, &bulk.DeleteIssuesInput{
    IssueIDs: []string{"PROJ-123", "PROJ-124", "PROJ-125"},
})

// Track bulk operation progress
progress, err := client.Bulk.GetProgress(ctx, taskID)
fmt.Printf("Operation is %d%% complete\n", progress.ProgressPercent)

// Wait for bulk operation to complete (blocking)
progress, err := client.Bulk.WaitForCompletion(ctx, taskID, 5*time.Second)
if progress.Status == bulk.BulkOperationStatusComplete {
    fmt.Printf("Success: %d items processed\n", progress.Result.SuccessCount)
}

Workflows

// Get available transitions for an issue
transitions, err := client.Workflow.GetTransitions(ctx, "PROJ-123", &workflow.GetTransitionsOptions{
    Expand: []string{"transitions.fields"},
})

// List all workflows
workflows, err := client.Workflow.List(ctx, &workflow.ListOptions{
    WorkflowName: "Classic",
})

// Get workflow by ID
workflow, err := client.Workflow.Get(ctx, "classic-default-workflow")

// Get all statuses
statuses, err := client.Workflow.GetAllStatuses(ctx)

// Get specific status
status, err := client.Workflow.GetStatus(ctx, "10000")

// Do transition on an issue
err = client.Workflow.DoTransition(ctx, "PROJ-123", &workflow.DoTransitionInput{
    Transition: &workflow.Transition{ID: "21"},
    Fields: map[string]interface{}{
        "resolution": map[string]string{"name": "Fixed"},
    },
})

// Status Categories
categories, err := client.Workflow.GetStatusCategories(ctx)
category, err := client.Workflow.GetStatusCategory(ctx, "2")

// Workflow Schemes
schemes, err := client.Workflow.ListWorkflowSchemes(ctx, nil)
scheme, err := client.Workflow.GetWorkflowScheme(ctx, 10000)

// Create workflow scheme
newScheme, err := client.Workflow.CreateWorkflowScheme(ctx, &workflow.CreateWorkflowSchemeInput{
    Name:        "Development Workflow",
    Description: "Custom workflow for dev team",
})

// Update workflow scheme
updated, err := client.Workflow.UpdateWorkflowScheme(ctx, 10000, &workflow.UpdateWorkflowSchemeInput{
    Name: "Updated Workflow",
})

// Delete workflow scheme
err = client.Workflow.DeleteWorkflowScheme(ctx, 10000)

Dashboards

// List all dashboards
dashboards, err := client.Dashboard.List(ctx, &dashboard.ListOptions{
    MaxResults: 50,
})

// Get specific dashboard
dash, err := client.Dashboard.Get(ctx, "10000")

// Create dashboard
newDash, err := client.Dashboard.Create(ctx, &dashboard.CreateDashboardInput{
    Name:        "Team Dashboard",
    Description: "Dashboard for team metrics",
    SharePermissions: []*dashboard.SharePermission{
        {Type: "global"},
    },
})

// Update dashboard
updated, err := client.Dashboard.Update(ctx, "10000", &dashboard.UpdateDashboardInput{
    Name:        "Updated Dashboard",
    Description: "New description",
})

// Delete dashboard
err = client.Dashboard.Delete(ctx, "10000")

// Copy dashboard
copy, err := client.Dashboard.Copy(ctx, "10000", &dashboard.CreateDashboardInput{
    Name: "Copied Dashboard",
})

// Dashboard Gadgets
// List gadgets on a dashboard
gadgets, err := client.Dashboard.GetGadgets(ctx, "10000")

// Add gadget to dashboard
newGadget, err := client.Dashboard.AddGadget(ctx, "10000", &dashboard.DashboardGadget{
    ModuleKey: "com.atlassian.jira.gadgets:filter-results-gadget",
    Position: &dashboard.GadgetPosition{
        Row:    0,
        Column: 0,
    },
    Properties: map[string]interface{}{
        "filterId": "10001",
    },
})

// Update gadget
updated, err = client.Dashboard.UpdateGadget(ctx, "10000", 12345, &dashboard.DashboardGadget{
    Position: &dashboard.GadgetPosition{
        Row:    1,
        Column: 1,
    },
})

// Remove gadget
err = client.Dashboard.RemoveGadget(ctx, "10000", 12345)

Groups

// Find groups
groups, err := client.Group.Find(ctx, &group.FindOptions{
    Query:      "developers",
    MaxResults: 50,
})

// Get group details
grp, err := client.Group.Get(ctx, &group.GetOptions{
    GroupName: "jira-developers",
    Expand:    []string{"users"},
})

// Create group
newGroup, err := client.Group.Create(ctx, &group.CreateGroupInput{
    Name: "new-team",
})

// Delete group
err = client.Group.Delete(ctx, &group.DeleteOptions{
    GroupName: "old-team",
})

// Group Membership
// Get group members
members, err := client.Group.GetMembers(ctx, &group.GetMembersOptions{
    GroupName:  "jira-developers",
    MaxResults: 50,
})

// Add user to group
updated, err := client.Group.AddUser(ctx, &group.AddUserOptions{
    GroupName: "jira-developers",
    AccountID: "accountId123",
})

// Remove user from group
err = client.Group.RemoveUser(ctx, &group.RemoveUserOptions{
    GroupName: "jira-developers",
    AccountID: "accountId123",
})

// Bulk get groups
groups, err = client.Group.BulkGet(ctx, &group.BulkOptions{
    GroupNames: []string{"team-1", "team-2", "team-3"},
})

Application Properties

// Get advanced settings
settings, err := client.AppProperties.GetAdvancedSettings(ctx)
for _, setting := range settings {
    fmt.Printf("%s = %s\n", setting.Key, setting.Value)
}

// Get specific application property
prop, err := client.AppProperties.GetApplicationProperty(ctx, "jira.title")
fmt.Printf("Jira Title: %s\n", prop.Value)

// Set application property
err = client.AppProperties.SetApplicationProperty(ctx, &appproperties.SetApplicationPropertyInput{
    Key:   "custom.setting",
    Value: "custom-value",
})

Server Info

// Get server information
info, err := client.ServerInfo.Get(ctx)
fmt.Printf("Jira Version: %s\n", info.Version)
fmt.Printf("Build: %d\n", info.BuildNumber)
fmt.Printf("Deployment Type: %s\n", info.DeploymentType)

// Get server configuration
config, err := client.ServerInfo.GetConfiguration(ctx)
fmt.Printf("Voting enabled: %v\n", config.VotingEnabled)
fmt.Printf("Time tracking enabled: %v\n", config.TimeTrackingEnabled)
fmt.Printf("Working hours per day: %.1f\n", config.TimeTrackingConfiguration.WorkingHoursPerDay)

Myself (Current User)

// Get current user
user, err := client.Myself.Get(ctx)
fmt.Printf("Display Name: %s\n", user.DisplayName)
fmt.Printf("Email: %s\n", user.EmailAddress)
fmt.Printf("Locale: %s\n", user.Locale)

// Get user preferences
prefs, err := client.Myself.GetPreferences(ctx)
fmt.Printf("Timezone: %s\n", prefs.TimeZone)

// Set user preferences
err = client.Myself.SetPreferences(ctx, &myself.Preferences{
    Locale:   "en_US",
    TimeZone: "America/New_York",
})

// Get specific preference
locale, err := client.Myself.GetPreference(ctx, "locale")

// Set specific preference
err = client.Myself.SetPreference(ctx, "locale", "de_DE")

// Delete preference
err = client.Myself.DeletePreference(ctx, "customSetting")

Jira Expressions

// Modern expression evaluation (v1.2.0+) - 30-50% faster
result, err := client.Expression.EvaluateExpression(ctx, &expression.EvaluationInput{
    Expression: "issue.summary",
    Context: map[string]interface{}{
        "issue": map[string]interface{}{
            "key": "PROJ-123",
        },
    },
})
fmt.Printf("Result: %v\n", result.Value)

// Check for evaluation errors
if len(result.Errors) > 0 {
    for _, evalErr := range result.Errors {
        fmt.Printf("Error: %s at line %d\n", evalErr.Message, evalErr.Line)
    }
}

// Legacy Evaluate() method (deprecated, will be removed Aug 1, 2025)
// Use EvaluateExpression() instead for better performance
result, err := client.Expression.Evaluate(ctx, &expression.EvaluationInput{
    Expression: "issue.summary",
})

// Analyze expressions for syntax and complexity
analysis, err := client.Expression.Analyze(ctx, &expression.AnalysisInput{
    Expressions: []string{
        "issue.summary",
        "user.displayName",
        "project.key + '-' + issue.id",
    },
})

for _, result := range analysis.Results {
    fmt.Printf("Expression: %s\n", result.Expression)
    fmt.Printf("Valid: %v\n", result.Valid)
    if result.Complexity != nil {
        fmt.Printf("Steps: %d\n", result.Complexity.Steps)
    }
}

Issue Link Types

// List all issue link types
linkTypes, err := client.IssueLinkType.List(ctx)
for _, lt := range linkTypes {
    fmt.Printf("%s: %s / %s\n", lt.Name, lt.Inward, lt.Outward)
}

// Get specific issue link type
linkType, err := client.IssueLinkType.Get(ctx, "10000")

// Create custom issue link type
newType, err := client.IssueLinkType.Create(ctx, &issuelinktype.CreateInput{
    Name:    "Dependency",
    Inward:  "depends on",
    Outward: "is depended on by",
})

// Update issue link type
updated, err := client.IssueLinkType.Update(ctx, "10000", &issuelinktype.UpdateInput{
    Name: "Updated Dependency",
})

// Delete issue link type
err = client.IssueLinkType.Delete(ctx, "10000")

Architecture

This library follows Hexagonal Architecture (Ports and Adapters) principles:

jira-connect/
β”œβ”€β”€ client.go              # Main client with functional options
β”œβ”€β”€ auth/                  # Authentication adapters
β”‚   β”œβ”€β”€ oauth2.go         # OAuth 2.0 (planned)
β”‚   β”œβ”€β”€ apitoken.go       # API Token
β”‚   └── pat.go            # Personal Access Token
β”œβ”€β”€ core/                  # Business logic & domain models
β”‚   β”œβ”€β”€ issue/            # Issue domain
β”‚   β”œβ”€β”€ project/          # Project domain
β”‚   β”œβ”€β”€ user/             # User domain
β”‚   └── workflow/         # Workflow domain
β”œβ”€β”€ transport/             # HTTP client abstraction
β”‚   β”œβ”€β”€ middleware.go     # Middleware chain
β”‚   └── backoff.go        # Retry logic
└── internal/             # Internal utilities
    └── pagination/       # Pagination helpers

Design Principles

1. Context-First API

All operations accept context.Context as the first parameter for cancellation and timeout control:

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

issue, err := client.Issue.Get(ctx, "PROJ-123", nil)

2. Functional Options Pattern

Flexible configuration without breaking backward compatibility:

client, err := jira.NewClient(
    jira.WithBaseURL("https://example.atlassian.net"),
    jira.WithAPIToken("email", "token"),
    jira.WithTimeout(30*time.Second),
    jira.WithMaxRetries(5),
)

3. Automatic Retry with Exponential Backoff

Retries are handled automatically for transient failures (5xx, 429):

  • Exponential backoff: min(100ms * 2^attempt, 30s)
  • Jitter: Β±25% randomization to avoid thundering herd
  • Context-aware: respects cancellation

4. Rate Limit Handling

Automatic detection and handling of rate limits:

  • Respects Retry-After header
  • Configurable buffer time
  • Transparent retry after waiting

5. Middleware Pipeline

Extensible request/response processing:

Request β†’ Retry β†’ RateLimit β†’ UserAgent β†’ Auth β†’ HTTP

Error Handling

All errors are wrapped with context for better debugging:

issue, err := client.Issue.Get(ctx, "INVALID", nil)
if err != nil {
    // Error includes full context: authentication failed, HTTP 401, etc.
    log.Printf("Error: %v", err)
}

Testing

# Run tests
go test ./...

# Run tests with coverage
go test -cover ./...

# Run tests with race detector
go test -race ./...

Examples

See the examples directory for complete, runnable examples:

Observability

Structured Logging with Bolt

The library integrates with bolt for zero-allocation structured logging:

import (
    jira "github.com/felixgeelhaar/jirasdk"
    boltadapter "github.com/felixgeelhaar/jirasdk/logger/bolt"
    "github.com/felixgeelhaar/bolt"
)

// Production: JSON logging
logger := bolt.New(bolt.NewJSONHandler(os.Stdout))
client, err := jira.NewClient(
    jira.WithBaseURL("https://your-domain.atlassian.net"),
    jira.WithAPIToken("email", "token"),
    jira.WithLogger(boltadapter.NewAdapter(logger)),
)

// Development: Console logging
consoleLogger := bolt.New(bolt.NewConsoleHandler(os.Stdout))
devClient, err := jira.NewClient(
    jira.WithBaseURL(baseURL),
    jira.WithAPIToken(email, token),
    jira.WithLogger(boltadapter.NewAdapter(consoleLogger)),
)

// With service context
contextLogger := logger.With().
    Str("service", "my-app").
    Str("version", "1.0.0").
    Logger()

Logging Features:

  • πŸ”₯ Zero allocations (63ns/op)
  • πŸ“Š Structured JSON output for production
  • 🎨 Colorized console output for development
  • πŸ” OpenTelemetry integration (automatic trace/span IDs)
  • πŸ“ˆ Request/response logging with duration and status codes
  • ⚑ Minimal overhead (<0.01% CPU impact)

Example Log Output:

{
  "level": "info",
  "method": "GET",
  "path": "/rest/api/3/issue/PROJ-123",
  "status": 200,
  "duration": 234,
  "rate_limit": "1000",
  "rate_limit_remaining": "999",
  "message": "jira_request_completed"
}

See examples/observability for complete examples.

Resilience Patterns with Fortify

The library integrates with fortify for production-grade resilience patterns:

import (
    jira "github.com/felixgeelhaar/jirasdk"
    "github.com/felixgeelhaar/jirasdk/resilience/fortify"
)

// Default resilience configuration (recommended)
resilience := fortify.NewAdapter(jira.DefaultResilienceConfig())
client, err := jira.NewClient(
    jira.WithBaseURL("https://your-domain.atlassian.net"),
    jira.WithAPIToken("email", "token"),
    jira.WithResilience(resilience),
)

// Custom resilience configuration
customConfig := jira.ResilienceConfig{
    // Circuit Breaker - Prevents cascading failures
    CircuitBreakerEnabled:   true,
    CircuitBreakerThreshold: 3,  // Open after 3 failures
    CircuitBreakerInterval:  30 * time.Second,
    CircuitBreakerTimeout:   60 * time.Second,

    // Retry - Handles transient failures
    RetryEnabled:      true,
    RetryMaxAttempts:  5,
    RetryInitialDelay: 50 * time.Millisecond,
    RetryMaxDelay:     5 * time.Second,
    RetryMultiplier:   2.0,
    RetryJitter:       true,

    // Rate Limiting - Complies with API quotas
    RateLimitEnabled: true,
    RateLimitRate:    50,  // 50 req/min
    RateLimitBurst:   5,
    RateLimitWindow:  60 * time.Second,

    // Timeout - Enforces time limits
    TimeoutEnabled:  true,
    TimeoutDuration: 10 * time.Second,

    // Bulkhead - Limits concurrent operations
    BulkheadEnabled:      true,
    BulkheadMaxConcurrent: 5,
    BulkheadMaxQueue:      10,
    BulkheadQueueTimeout:  3 * time.Second,
}

aggressiveResilience := fortify.NewAdapter(customConfig)

Resilience Patterns:

  1. πŸ”Œ Circuit Breaker - Fast failure for unhealthy services

    • States: Closed β†’ Open β†’ Half-Open β†’ Closed
    • Prevents cascading failures
    • Automatic recovery attempts
    • ~30ns overhead, 0 allocations
  2. πŸ”„ Retry with Exponential Backoff - Handles transient failures

    • Exponential backoff with configurable multiplier
    • Jitter to prevent thundering herd
    • Configurable max attempts and delays
    • ~25ns overhead, 0 allocations
  3. ⏱️ Rate Limiting (Token Bucket) - API quota compliance

    • Token bucket algorithm with burst capacity
    • Configurable rate and window
    • Automatic request throttling
    • ~45ns overhead, 0 allocations
  4. ⏰ Timeout - Enforces operation deadlines

    • Per-request timeout enforcement
    • Prevents resource leaks
    • SLA compliance
    • ~50ns overhead, 0 allocations
  5. 🚧 Bulkhead - Concurrency control

    • Limits concurrent operations
    • Queue management with timeout
    • Prevents resource exhaustion
    • ~39ns overhead, 0 allocations

Pattern Composition Order:

Request Flow:
  1. Bulkhead    β†’ Check concurrency limit
  2. Rate Limit  β†’ Check quota (blocks if needed)
  3. Timeout     β†’ Wrap request with deadline
  4. Circuit Breaker β†’ Check service health
  5. Retry       β†’ Handle transient failures
  6. HTTP Request β†’ Finally execute

Performance Characteristics:

  • Total overhead: <200ns per request (<1Β΅s)
  • Zero allocations for all patterns
  • Negligible CPU impact (<0.01%)
  • Minimal memory footprint

Default Configuration:

jira.DefaultResilienceConfig()
// Returns:
//   Circuit Breaker: 5 failures/60s β†’ open for 30s
//   Retry: 3 attempts, 100ms-10s backoff, jitter enabled
//   Rate Limit: 100 req/min, burst 10 (Jira Cloud defaults)
//   Timeout: 30s
//   Bulkhead: 10 concurrent, 20 queued, 5s queue timeout

Use Cases:

Pattern When to Use
Circuit Breaker External dependencies, preventing cascading failures
Retry Transient network failures, rate-limited APIs
Rate Limiting Complying with API quotas, fair resource usage
Timeout Enforcing SLAs, preventing resource leaks
Bulkhead Preventing resource exhaustion, isolating critical operations

See examples/resilience for complete examples including pattern explanations, custom configurations, and use case demonstrations.

Roadmap

Phase 1: Foundation βœ… Complete

  • Core client architecture
  • Authentication (API Token, PAT, Basic Auth)
  • HTTP transport with middleware
  • Retry logic and rate limiting
  • Comprehensive testing (80%+ coverage)

Phase 2: Core Resources βœ… Complete

  • Issue CRUD operations
  • Project management
  • User operations
  • Workflow transitions
  • JQL search with QueryBuilder
  • Comments and watchers/voters
  • Pagination support

Phase 3: Advanced Features βœ… Complete

  • Custom fields support with type-safe API
  • Attachments upload/download
  • Issue linking (blocks, duplicates, relates, causes, clones)
  • Time tracking and worklogs
  • OAuth 2.0 authentication

Phase 4: Enterprise Features βœ… Complete

  • Enhanced workflow operations (transitions, statuses, schemes)
  • Enhanced project configuration (component and version management)
  • Agile/Scrum features (boards, sprints, epics, backlog)
  • Permissions API (schemes, project roles, permission checking)
  • Bulk operations (create, delete, progress tracking)

Phase 5: Observability & Resilience βœ… Complete

  • Structured logging with bolt integration (zero-allocation)
  • Request/response logging with duration and status codes
  • OpenTelemetry trace/span ID support
  • Resilience patterns with fortify integration
  • Circuit breakers for fault tolerance
  • Enhanced retry logic with exponential backoff and jitter
  • Rate limiting with token bucket algorithm
  • Timeout enforcement with context propagation
  • Bulkheads for concurrency control

Phase 6: Extended API Coverage βœ… Complete

  • Dashboard management (CRUD operations, gadget management)
  • Group administration (membership, bulk operations)
  • Application properties (advanced settings, configuration)
  • Server information (instance metadata, health checks)
  • Current user preferences (locale, timezone, custom settings)
  • Jira Expressions (evaluation, analysis, complexity checking)
  • Issue Link Types (custom relationship management)
  • Enhanced User operations (properties, groups, permissions)
  • Enhanced Workflow operations (schemes, status categories, transitions)

Phase 7: Metrics & Advanced Features πŸ“‹ Planned

  • Prometheus metrics integration
  • Webhook support for Jira events
  • Connection pooling optimization
  • GraphQL API support
  • Batch request optimization

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for details.

License

MIT License - see LICENSE for details.

Acknowledgments

Built with inspiration from:

Support

About

Enterprise-grade Go client for Jira Cloud & Server/Data Center REST APIs. Features resilience patterns, environment config, zero-allocation logging, and comprehensive documentation. Production-ready with full context support.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •  

Languages