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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ atl --context prod jira issue transition PROJ-1234 --list # List available t
atl --context prod jira issue transition PROJ-1234 "Done" --field "Resolution=Fixed" # With required fields
```

`resolution` and `priority` take a name or an id; an all-digit value is sent as an id.

### Comments

```bash
Expand Down
45 changes: 35 additions & 10 deletions internal/cmd/issue/field_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,35 @@ import (
"github.com/enthus-appdev/atl-cli/internal/api"
)

// systemFieldKeys maps a lowercased Jira system field name to the key the
// REST API expects in a fields payload. Jira matches those keys
// case-sensitively, so "Resolution" is rejected as an unknown field.
var systemFieldKeys = map[string]string{
"summary": "summary", "description": "description", "issuetype": "issuetype",
"project": "project", "reporter": "reporter", "assignee": "assignee",
"priority": "priority", "labels": "labels", "components": "components",
"fixversions": "fixVersions", "versions": "versions", "duedate": "duedate",
"environment": "environment", "resolution": "resolution", "status": "status",
"created": "created", "updated": "updated", "parent": "parent",
"security": "security",
}

// referenceFields are system fields whose value must be a reference object
// ({"id": ...} or {"name": ...}); Jira rejects a bare string or number.
var referenceFields = map[string]bool{"resolution": true, "priority": true}
Comment thread
Hinne1 marked this conversation as resolved.

// 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,
"security": true,
_, ok := systemFieldKeys[strings.ToLower(name)]
return ok
}

func referenceValue(value string) map[string]string {
trimmed := strings.TrimSpace(value)
if strings.Trim(trimmed, "0123456789") == "" {
return map[string]string{"id": trimmed}
}
return systemFields[strings.ToLower(name)]
return map[string]string{"name": trimmed}
}
Comment thread
Hinne1 marked this conversation as resolved.

// projectKeyFromIssueKey returns the project key portion of an issue key
Expand Down Expand Up @@ -100,7 +117,15 @@ func ParseCustomField(ctx context.Context, jira *api.JiraService, raw string) (s

if strings.HasPrefix(key, "customfield_") {
resolvedField, _ = jira.GetFieldByID(ctx, key)
} else if !isSystemField(key) {
} else if canonical, ok := systemFieldKeys[strings.ToLower(key)]; ok {
key = canonical
if referenceFields[key] {
if strings.TrimSpace(value) == "" {
return "", nil, fmt.Errorf("field %s requires a value (id or name)", key)
}
return key, referenceValue(value), nil
}
} else {
Comment thread
Hinne1 marked this conversation as resolved.
Comment thread
Hinne1 marked this conversation as resolved.
var err error
resolvedField, err = jira.GetFieldByName(ctx, key)
if err != nil {
Expand Down
57 changes: 57 additions & 0 deletions internal/cmd/issue/field_util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,60 @@ func TestCoerceFieldValue_RadioStillWorks(t *testing.T) {
t.Errorf("got %v, want %v", gotMap, want)
}
}

// TestParseCustomField_SystemFields covers system fields, which resolve without
// an API call: keys are sent in Jira's casing, and reference fields are wrapped
// in an id or name object because Jira rejects a bare value for them.
func TestParseCustomField_SystemFields(t *testing.T) {
tests := []struct {
raw string
wantKey string
want interface{}
}{
{"Resolution=Won't Do", "resolution", map[string]string{"name": "Won't Do"}},
{"resolution=10001", "resolution", map[string]string{"id": "10001"}},
{"Priority=High", "priority", map[string]string{"name": "High"}},
{"priority= 3 ", "priority", map[string]string{"id": "3"}},
{"Summary=New title", "summary", "New title"},
}
Comment thread
Hinne1 marked this conversation as resolved.
Comment thread
Hinne1 marked this conversation as resolved.
for _, tt := range tests {
t.Run(tt.raw, func(t *testing.T) {
key, got, err := ParseCustomField(t.Context(), nil, tt.raw)
if err != nil {
t.Fatalf("ParseCustomField(%q) error: %v", tt.raw, err)
}
if key != tt.wantKey {
t.Errorf("key = %q, want %q", key, tt.wantKey)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("value = %#v, want %#v", got, tt.want)
}
})
}
}

// TestParseCustomField_SystemFieldKeyCasing guards that a system field key is
// sent in the casing Jira expects, whatever the user typed.
func TestParseCustomField_SystemFieldKeyCasing(t *testing.T) {
for raw, want := range map[string]string{
"FixVersions=v2": "fixVersions",
"fixversions=v2": "fixVersions",
"DueDate=2026-10-1": "duedate",
} {
key, _, err := ParseCustomField(t.Context(), nil, raw)
if err != nil {
t.Fatalf("ParseCustomField(%q) error: %v", raw, err)
}
if key != want {
t.Errorf("ParseCustomField(%q) key = %q, want %q", raw, key, want)
}
}
}

func TestParseCustomField_EmptyReferenceValue(t *testing.T) {
for _, raw := range []string{"Resolution=", "priority= "} {
if _, _, err := ParseCustomField(t.Context(), nil, raw); err == nil {
t.Errorf("ParseCustomField(%q) error = nil, want a missing-value error", raw)
}
}
}
Loading