Skip to content
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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ Aliases also work with `--hostname` flags: `atl auth status --hostname prod`
### View Issues

```bash
atl issue view PROJ-1234 # View issue details
atl issue view PROJ-1234 --json # View as JSON (for parsing)
atl issue view PROJ-1234 # View issue details (includes custom fields)
atl issue view PROJ-1234 --json # View as JSON (includes custom_fields section)
atl issue view PROJ-1234 --web # Open in browser
```

Expand Down Expand Up @@ -87,6 +87,7 @@ atl issue edit PROJ-1234 --field "Custom Field=Some **markdown** text" # Auto-c
```bash
atl issue transition PROJ-1234 "In Progress"
atl issue transition PROJ-1234 --list # List available transitions
atl issue transition PROJ-1234 "Done" --field "Resolution=Fixed" # With required fields
```

### Comments
Expand Down Expand Up @@ -132,6 +133,8 @@ atl issue types --project PROJ # List issue types
atl issue priorities # List available priorities
atl issue fields # List all fields
atl issue fields --search "story points" # Search for field by name
atl issue field-options --project PROJ --type Bug # Show allowed values for fields
atl issue field-options --project PROJ --type Bug --field "Priority" # Specific field
```

## Jira Boards
Expand Down
158 changes: 155 additions & 3 deletions internal/api/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (

// JiraService handles Jira API operations.
type JiraService struct {
client *Client
client *Client
fieldsCache []*Field
}

// NewJiraService creates a new Jira service.
Expand Down Expand Up @@ -47,6 +48,103 @@ type IssueFields struct {
Comment *Comments `json:"comment,omitempty"`
Parent *Issue `json:"parent,omitempty"`
Attachment []*Attachment `json:"attachment,omitempty"`

// Extra holds custom field values not captured by the typed fields above.
// Keys are field IDs like "customfield_10413", values are raw JSON.
Extra map[string]json.RawMessage `json:"-"`
}

// UnmarshalJSON implements custom unmarshaling to capture both standard
// and custom fields. Standard fields are decoded into typed struct fields,
// while custom fields (customfield_*) are preserved as raw JSON in Extra.
func (f *IssueFields) UnmarshalJSON(data []byte) error {
type Alias IssueFields
var alias Alias
if err := json.Unmarshal(data, &alias); err != nil {
return err
}
*f = IssueFields(alias)

var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}

f.Extra = make(map[string]json.RawMessage)
for key, val := range raw {
if strings.HasPrefix(key, "customfield_") {
f.Extra[key] = val
}
}

return nil
}

// FormatCustomFieldValue extracts a human-readable string from a raw JSON
// custom field value. Handles common Jira field value shapes:
// select/radio, user, ADF, arrays, strings, numbers, null.
func FormatCustomFieldValue(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}

// Try as object with "value" key (select/radio fields).
var selectField struct {
Value string `json:"value"`
}
if err := json.Unmarshal(raw, &selectField); err == nil && selectField.Value != "" {
return selectField.Value
}

// Try as object with "displayName" key (user fields).
var userField struct {
DisplayName string `json:"displayName"`
}
if err := json.Unmarshal(raw, &userField); err == nil && userField.DisplayName != "" {
return userField.DisplayName
}

// Try as ADF document.
var adfField struct {
Type string `json:"type"`
Version int `json:"version"`
}
if err := json.Unmarshal(raw, &adfField); err == nil && adfField.Type == "doc" {
var adfDoc ADF
if err := json.Unmarshal(raw, &adfDoc); err == nil {
return ADFToText(&adfDoc)
}
}

// Try as array.
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
var values []string
for _, item := range arr {
formatted := FormatCustomFieldValue(item)
if formatted != "" {
values = append(values, formatted)
}
}
Comment on lines 123 to 128

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation for handling array items only checks for select-like objects ({ "value": ... }) and plain strings. This is incomplete as an array can contain other complex types that FormatCustomFieldValue can handle, such as user objects ({ "displayName": ... }).

To make this more robust and handle all supported types within an array, you can use a recursive call to FormatCustomFieldValue for each item.

This simplifies the code and correctly formats any nested types that the parent function already knows how to handle.

for _, item := range arr {
				// Recursively format each item to handle various nested types like user pickers, etc.
				formattedItem := FormatCustomFieldValue(item)
				if formattedItem != "" {
					values = append(values, formattedItem)
				}
			}

return strings.Join(values, ", ")
}

// Try as string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}

// Try as number.
var n float64
if err := json.Unmarshal(raw, &n); err == nil {
if n == float64(int64(n)) {
return fmt.Sprintf("%d", int64(n))
}
return fmt.Sprintf("%g", n)
}

return string(raw)
}

// Attachment represents an attachment on an issue.
Expand Down Expand Up @@ -434,6 +532,52 @@ func (s *JiraService) GetSubtaskType(ctx context.Context, projectKey string) (*P
return nil, nil
}

// FieldMeta represents metadata for a field from the createmeta endpoint.
type FieldMeta struct {
Required bool `json:"required"`
Schema *FieldSchema `json:"schema,omitempty"`
Name string `json:"name"`
Key string `json:"key"`
FieldID string `json:"fieldId"`
AllowedValues []json.RawMessage `json:"allowedValues,omitempty"`
}

// FieldMetaResponse is the paginated response from the createmeta field endpoint.
type FieldMetaResponse struct {
MaxResults int `json:"maxResults"`
StartAt int `json:"startAt"`
Total int `json:"total"`
Values []*FieldMeta `json:"values"`
}

// GetFieldOptions gets field metadata (including allowed values) for a project/issue type.
// Uses the createmeta endpoint: /issue/createmeta/{projectKey}/issuetypes/{issueTypeId}
func (s *JiraService) GetFieldOptions(ctx context.Context, projectKey, issueTypeID string) ([]*FieldMeta, error) {
path := fmt.Sprintf("%s/issue/createmeta/%s/issuetypes/%s",
s.client.JiraBaseURL(), url.PathEscape(projectKey), url.PathEscape(issueTypeID))

params := url.Values{}
params.Set("maxResults", "100")

var allFields []*FieldMeta
startAt := 0

for {
params.Set("startAt", strconv.Itoa(startAt))
var result FieldMetaResponse
if err := s.client.Get(ctx, path+"?"+params.Encode(), &result); err != nil {
return nil, err
}
allFields = append(allFields, result.Values...)
if startAt+result.MaxResults >= result.Total {
break
}
startAt += result.MaxResults
}

return allFields, nil
}

// GetPriorities gets all available priorities in the Jira instance.
func (s *JiraService) GetPriorities(ctx context.Context) ([]*Priority, error) {
path := fmt.Sprintf("%s/priority", s.client.JiraBaseURL())
Expand Down Expand Up @@ -479,7 +623,8 @@ func (s *JiraService) GetTransitions(ctx context.Context, key string) ([]*Transi

// TransitionRequest represents a request to transition an issue.
type TransitionRequest struct {
Transition TransitionID `json:"transition"`
Transition TransitionID `json:"transition"`
Fields map[string]interface{} `json:"fields,omitempty"`
}

// TransitionID identifies a transition.
Expand All @@ -488,10 +633,12 @@ type TransitionID struct {
}

// TransitionIssue transitions an issue to a new status.
func (s *JiraService) TransitionIssue(ctx context.Context, key string, transitionID string) error {
// Optional fields can be set during the transition.
func (s *JiraService) TransitionIssue(ctx context.Context, key string, transitionID string, fields map[string]interface{}) error {
path := fmt.Sprintf("%s/issue/%s/transitions", s.client.JiraBaseURL(), key)
req := &TransitionRequest{
Transition: TransitionID{ID: transitionID},
Fields: fields,
}
return s.client.Post(ctx, path, req, nil)
}
Expand Down Expand Up @@ -799,13 +946,18 @@ type FieldSchema struct {

// GetFields gets all field definitions.
func (s *JiraService) GetFields(ctx context.Context) ([]*Field, error) {
if s.fieldsCache != nil {
return s.fieldsCache, nil
}

path := fmt.Sprintf("%s/field", s.client.JiraBaseURL())

var fields []*Field
if err := s.client.Get(ctx, path, &fields); err != nil {
return nil, err
}

s.fieldsCache = fields
return fields, nil
}

Expand Down
41 changes: 4 additions & 37 deletions internal/cmd/issue/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -218,30 +217,11 @@ func runCreate(opts *CreateOptions) error {
req.Fields.CustomFields = make(map[string]interface{})
}
for _, field := range opts.CustomFields {
parts := strings.SplitN(field, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid field format: %s (expected key=value)", field)
}
key, value := parts[0], parts[1]

// If key doesn't look like a field ID, try to resolve it by name
if !strings.HasPrefix(key, "customfield_") && !isSystemField(key) {
resolvedField, err := jira.GetFieldByName(ctx, key)
if err != nil {
return fmt.Errorf("failed to look up field '%s': %w", key, err)
}
if resolvedField == nil {
return fmt.Errorf("field not found: %s\n\nUse 'atl issue fields --search \"%s\"' to find available fields", key, key)
}
key = resolvedField.ID
}

// Try to parse value as number, otherwise use string
if numVal, err := strconv.ParseFloat(value, 64); err == nil {
req.Fields.CustomFields[key] = numVal
} else {
req.Fields.CustomFields[key] = value
key, fieldValue, err := ParseCustomField(ctx, jira, field)
if err != nil {
return err
}
req.Fields.CustomFields[key] = fieldValue
}
}

Expand Down Expand Up @@ -274,16 +254,3 @@ func runCreate(opts *CreateOptions) error {

return nil
}

// isSystemField checks if a field name is a known Jira system field.
func isSystemField(name string) bool {
systemFields := map[string]bool{
"summary": true, "description": true, "issuetype": true,
"project": true, "reporter": true, "assignee": true,
"priority": true, "labels": true, "components": true,
"fixVersions": true, "versions": true, "duedate": true,
"environment": true, "resolution": true, "status": true,
"created": true, "updated": true, "parent": true,
}
return systemFields[strings.ToLower(name)]
}
45 changes: 3 additions & 42 deletions internal/cmd/issue/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -222,47 +221,9 @@ func runEdit(opts *EditOptions) error {

// Parse and add custom fields from command line (override file values)
for _, field := range opts.CustomFields {
parts := strings.SplitN(field, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid field format: %s (expected key=value)", field)
}
key, value := parts[0], parts[1]

var resolvedField *api.Field
var err error

if strings.HasPrefix(key, "customfield_") {
// Look up field by ID to get type information
resolvedField, err = jira.GetFieldByID(ctx, key)
if err != nil {
return fmt.Errorf("failed to look up field '%s': %w", key, err)
}
// Note: resolvedField may be nil if field doesn't exist, we'll still try to set it
} else if !isSystemField(key) {
// Resolve field by name
resolvedField, err = jira.GetFieldByName(ctx, key)
if err != nil {
return fmt.Errorf("failed to look up field '%s': %w", key, err)
}
if resolvedField == nil {
return fmt.Errorf("field not found: %s\n\nUse 'atl issue fields --search \"%s\"' to find available fields", key, key)
}
key = resolvedField.ID
}

// Determine field value based on field type
var fieldValue interface{}

// Check if this is a textarea field that requires ADF format
if resolvedField != nil && resolvedField.Schema != nil &&
strings.Contains(resolvedField.Schema.Custom, "textarea") {
// Convert Markdown to ADF for textarea fields
fieldValue = api.TextToADF(value)
} else if numVal, err := strconv.ParseFloat(value, 64); err == nil {
// Try to parse value as number
fieldValue = numVal
} else {
fieldValue = value
key, fieldValue, err := ParseCustomField(ctx, jira, field)
if err != nil {
return err
}
req.Fields[key] = fieldValue
editOutput.FieldsUpdated = append(editOutput.FieldsUpdated, key)
Expand Down
Loading