From 99b729523a1f406ed08e3655cb31c85083e9db5d Mon Sep 17 00:00:00 2001 From: Will Hegedus Date: Thu, 18 Dec 2025 13:00:11 -0500 Subject: [PATCH 1/2] fix: handle escaped commas in custom field array values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom field array values can now contain commas by escaping them with a backslash (\,). Previously, values like "WL: Tools, Development and Support" would be incorrectly split into multiple array elements. Changes: - Added splitUnescapedCommas() function to handle escaped commas - Updated constructCustomFields() in create.go - Updated constructCustomFieldsForEdit() in edit.go - Added comprehensive test coverage for both create and edit operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- pkg/jira/create.go | 2 +- pkg/jira/create_test.go | 86 ++++++++++++++++++++++++++++++++++++ pkg/jira/customfield.go | 41 ++++++++++++++++++ pkg/jira/edit.go | 2 +- pkg/jira/edit_test.go | 96 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 pkg/jira/edit_test.go diff --git a/pkg/jira/create.go b/pkg/jira/create.go index bec14918..3f6e4f3c 100644 --- a/pkg/jira/create.go +++ b/pkg/jira/create.go @@ -247,7 +247,7 @@ func constructCustomFields(fields map[string]string, configuredFields []IssueTyp case customFieldFormatProject: data.Fields.M.customFields[configured.Key] = customFieldTypeProject{Value: val} case customFieldFormatArray: - pieces := strings.Split(strings.TrimSpace(val), ",") + pieces := splitUnescapedCommas(val) if configured.Schema.Items == customFieldFormatOption { items := make([]customFieldTypeOption, 0) for _, p := range pieces { diff --git a/pkg/jira/create_test.go b/pkg/jira/create_test.go index fab756c4..5bc864f7 100644 --- a/pkg/jira/create_test.go +++ b/pkg/jira/create_test.go @@ -178,3 +178,89 @@ func TestCreateEpicNextGen(t *testing.T) { _, err = client.CreateV2(&requestData) assert.Error(t, &ErrUnexpectedResponse{}, err) } + +func TestCreateWithCustomFieldArrayEscapedComma(t *testing.T) { + expectedBody := `{"update":{},"fields":{"project":{"key":"TEST"},"issuetype":{"name":"Task"},` + + `"summary":"Test task","customfield_10050":["WL: Tools, Development and Support"]}}` + testServer := createTestServer{code: 201} + server := testServer.serve(t, expectedBody) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + customFields := []IssueTypeField{ + { + Name: "Work Category", + Key: "customfield_10050", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{ + DataType: "array", + Items: "string", + }, + }, + } + + requestData := CreateRequest{ + Project: "TEST", + IssueType: "Task", + Summary: "Test task", + CustomFields: map[string]string{ + "work-category": `WL: Tools\, Development and Support`, + }, + } + requestData.WithCustomFields(customFields) + + actual, err := client.CreateV2(&requestData) + assert.NoError(t, err) + + expected := &CreateResponse{ + ID: "10057", + Key: "TEST-3", + } + assert.Equal(t, expected, actual) +} + +func TestCreateWithCustomFieldArrayMultipleValues(t *testing.T) { + expectedBody := `{"update":{},"fields":{"project":{"key":"TEST"},"issuetype":{"name":"Task"},` + + `"summary":"Test task","customfield_10051":["Value 1","Value 2, with comma","Value 3"]}}` + testServer := createTestServer{code: 201} + server := testServer.serve(t, expectedBody) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + customFields := []IssueTypeField{ + { + Name: "Multi Value Field", + Key: "customfield_10051", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{ + DataType: "array", + Items: "string", + }, + }, + } + + requestData := CreateRequest{ + Project: "TEST", + IssueType: "Task", + Summary: "Test task", + CustomFields: map[string]string{ + "multi-value-field": `Value 1,Value 2\, with comma,Value 3`, + }, + } + requestData.WithCustomFields(customFields) + + actual, err := client.CreateV2(&requestData) + assert.NoError(t, err) + + expected := &CreateResponse{ + ID: "10057", + Key: "TEST-3", + } + assert.Equal(t, expected, actual) +} diff --git a/pkg/jira/customfield.go b/pkg/jira/customfield.go index 92f3c675..1e75836c 100644 --- a/pkg/jira/customfield.go +++ b/pkg/jira/customfield.go @@ -1,5 +1,7 @@ package jira +import "strings" + const ( customFieldFormatOption = "option" customFieldFormatArray = "array" @@ -39,3 +41,42 @@ type customFieldTypeProject struct { type customFieldTypeProjectSet struct { Set customFieldTypeProject `json:"set"` } + +// splitUnescapedCommas splits a string on commas that are not escaped with backslash. +// Escaped commas (\,) are unescaped in the resulting strings. +func splitUnescapedCommas(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return []string{} + } + + var result []string + var current strings.Builder + escaped := false + + for i := 0; i < len(s); i++ { + if escaped { + if s[i] == ',' { + current.WriteByte(',') + } else { + current.WriteByte('\\') + current.WriteByte(s[i]) + } + escaped = false + } else if s[i] == '\\' { + escaped = true + } else if s[i] == ',' { + result = append(result, strings.TrimSpace(current.String())) + current.Reset() + } else { + current.WriteByte(s[i]) + } + } + + if escaped { + current.WriteByte('\\') + } + + result = append(result, strings.TrimSpace(current.String())) + return result +} diff --git a/pkg/jira/edit.go b/pkg/jira/edit.go index a8e4bcd2..5ea9ccf1 100644 --- a/pkg/jira/edit.go +++ b/pkg/jira/edit.go @@ -378,7 +378,7 @@ func constructCustomFieldsForEdit(fields map[string]string, configuredFields []I case customFieldFormatProject: data.Update.M.customFields[configured.Key] = []customFieldTypeProjectSet{{Set: customFieldTypeProject{Value: val}}} case customFieldFormatArray: - pieces := strings.Split(strings.TrimSpace(val), ",") + pieces := splitUnescapedCommas(val) if configured.Schema.Items == customFieldFormatOption { items := make([]customFieldTypeOptionAddRemove, 0) for _, p := range pieces { diff --git a/pkg/jira/edit_test.go b/pkg/jira/edit_test.go new file mode 100644 index 00000000..2e0ceb42 --- /dev/null +++ b/pkg/jira/edit_test.go @@ -0,0 +1,96 @@ +package jira + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type editTestServer struct{ code int } + +func (e *editTestServer) serve(t *testing.T, expectedBody string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-123", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + actualBody := new(strings.Builder) + _, _ = io.Copy(actualBody, r.Body) + + assert.JSONEq(t, expectedBody, actualBody.String()) + + w.WriteHeader(e.code) + })) +} + +func TestEditWithCustomFieldArrayEscapedComma(t *testing.T) { + expectedBody := `{"update":{"customfield_10050":["WL: Tools, Development and Support"]},"fields":{"parent":{}}}` + testServer := editTestServer{code: 204} + server := testServer.serve(t, expectedBody) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + customFields := []IssueTypeField{ + { + Name: "Work Category", + Key: "customfield_10050", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{ + DataType: "array", + Items: "string", + }, + }, + } + + requestData := EditRequest{ + CustomFields: map[string]string{ + "work-category": `WL: Tools\, Development and Support`, + }, + } + requestData.WithCustomFields(customFields) + + err := client.Edit("TEST-123", &requestData) + assert.NoError(t, err) +} + +func TestEditWithCustomFieldArrayMultipleValues(t *testing.T) { + expectedBody := `{"update":{"customfield_10051":["Value 1","Value 2, with comma","Value 3"]},"fields":{"parent":{}}}` + testServer := editTestServer{code: 204} + server := testServer.serve(t, expectedBody) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + customFields := []IssueTypeField{ + { + Name: "Multi Value Field", + Key: "customfield_10051", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{ + DataType: "array", + Items: "string", + }, + }, + } + + requestData := EditRequest{ + CustomFields: map[string]string{ + "multi-value-field": `Value 1,Value 2\, with comma,Value 3`, + }, + } + requestData.WithCustomFields(customFields) + + err := client.Edit("TEST-123", &requestData) + assert.NoError(t, err) +} From 16d6199d1853dfe3149df93b8814452228f76aca Mon Sep 17 00:00:00 2001 From: Will Hegedus Date: Thu, 18 Dec 2025 13:15:10 -0500 Subject: [PATCH 2/2] lint: fix linting issue about switch/case usage --- pkg/jira/customfield.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/jira/customfield.go b/pkg/jira/customfield.go index 1e75836c..67d75f94 100644 --- a/pkg/jira/customfield.go +++ b/pkg/jira/customfield.go @@ -55,7 +55,8 @@ func splitUnescapedCommas(s string) []string { escaped := false for i := 0; i < len(s); i++ { - if escaped { + switch { + case escaped: if s[i] == ',' { current.WriteByte(',') } else { @@ -63,12 +64,12 @@ func splitUnescapedCommas(s string) []string { current.WriteByte(s[i]) } escaped = false - } else if s[i] == '\\' { + case s[i] == '\\': escaped = true - } else if s[i] == ',' { + case s[i] == ',': result = append(result, strings.TrimSpace(current.String())) current.Reset() - } else { + default: current.WriteByte(s[i]) } }