From 1b02408f21f93f1677e8b763b76295819d31cb1e Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 24 Sep 2026 15:23:57 +0200 Subject: [PATCH 1/3] fix: send system field keys in Jira casing and wrap resolution/priority as references atl jira issue transition/edit/create --field sent a system field under the user's casing ("Resolution") and its value as a bare string or number. Jira matches field keys case-sensitively and requires resolution and priority as an {id} or {name} object, so a transition that requires a resolution could not be completed through atl. --- internal/cmd/issue/field_util.go | 44 ++++++++++++++++++------ internal/cmd/issue/field_util_test.go | 49 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/internal/cmd/issue/field_util.go b/internal/cmd/issue/field_util.go index 2ba236f..cb6c244 100644 --- a/internal/cmd/issue/field_util.go +++ b/internal/cmd/issue/field_util.go @@ -9,18 +9,37 @@ 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} + // 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 +} + +// referenceValue builds the reference object for a reference field: an +// all-digit value is an id, anything else a name. +func referenceValue(value string) map[string]string { + trimmed := strings.TrimSpace(value) + if trimmed != "" && strings.Trim(trimmed, "0123456789") == "" { + return map[string]string{"id": trimmed} } - return systemFields[strings.ToLower(name)] + return map[string]string{"name": trimmed} } // projectKeyFromIssueKey returns the project key portion of an issue key @@ -100,7 +119,12 @@ 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] { + return key, referenceValue(value), nil + } + } else { var err error resolvedField, err = jira.GetFieldByName(ctx, key) if err != nil { diff --git a/internal/cmd/issue/field_util_test.go b/internal/cmd/issue/field_util_test.go index a21f185..b20b8a9 100644 --- a/internal/cmd/issue/field_util_test.go +++ b/internal/cmd/issue/field_util_test.go @@ -233,3 +233,52 @@ 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"}, + } + 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) + } + } +} From 3d5b2e0dd45185e3be08506d30f1d3c6d863dc9e Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 24 Sep 2026 16:15:53 +0200 Subject: [PATCH 2/3] chore: drop comment restating referenceValue --- internal/cmd/issue/field_util.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/cmd/issue/field_util.go b/internal/cmd/issue/field_util.go index cb6c244..a3fe0b0 100644 --- a/internal/cmd/issue/field_util.go +++ b/internal/cmd/issue/field_util.go @@ -32,8 +32,6 @@ func isSystemField(name string) bool { return ok } -// referenceValue builds the reference object for a reference field: an -// all-digit value is an id, anything else a name. func referenceValue(value string) map[string]string { trimmed := strings.TrimSpace(value) if trimmed != "" && strings.Trim(trimmed, "0123456789") == "" { From 3714b9841f41226f292cdab5e6a0f078cfb53891 Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Thu, 24 Sep 2026 16:19:33 +0200 Subject: [PATCH 3/3] fix: refuse an empty resolution or priority value before calling Jira --- AGENTS.md | 2 ++ internal/cmd/issue/field_util.go | 5 ++++- internal/cmd/issue/field_util_test.go | 8 ++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d1d7617..84bbc4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/internal/cmd/issue/field_util.go b/internal/cmd/issue/field_util.go index a3fe0b0..b742344 100644 --- a/internal/cmd/issue/field_util.go +++ b/internal/cmd/issue/field_util.go @@ -34,7 +34,7 @@ func isSystemField(name string) bool { func referenceValue(value string) map[string]string { trimmed := strings.TrimSpace(value) - if trimmed != "" && strings.Trim(trimmed, "0123456789") == "" { + if strings.Trim(trimmed, "0123456789") == "" { return map[string]string{"id": trimmed} } return map[string]string{"name": trimmed} @@ -120,6 +120,9 @@ func ParseCustomField(ctx context.Context, jira *api.JiraService, raw string) (s } 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 { diff --git a/internal/cmd/issue/field_util_test.go b/internal/cmd/issue/field_util_test.go index b20b8a9..06af858 100644 --- a/internal/cmd/issue/field_util_test.go +++ b/internal/cmd/issue/field_util_test.go @@ -282,3 +282,11 @@ func TestParseCustomField_SystemFieldKeyCasing(t *testing.T) { } } } + +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) + } + } +}