Skip to content
Open
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: 1 addition & 1 deletion pkg/jira/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions pkg/jira/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
42 changes: 42 additions & 0 deletions pkg/jira/customfield.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package jira

import "strings"

const (
customFieldFormatOption = "option"
customFieldFormatArray = "array"
Expand Down Expand Up @@ -39,3 +41,43 @@ 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++ {
switch {
case escaped:
if s[i] == ',' {
current.WriteByte(',')
} else {
current.WriteByte('\\')
current.WriteByte(s[i])
Comment on lines +63 to +64
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

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

When a backslash is followed by a character other than a comma, both the backslash and the character are preserved. This behavior should be documented in the function's comment to clarify that only \, is treated as an escape sequence, while other backslash sequences like \n or \t remain literal.

Copilot uses AI. Check for mistakes.
}
escaped = false
case s[i] == '\\':
escaped = true
case s[i] == ',':
result = append(result, strings.TrimSpace(current.String()))
current.Reset()
default:
current.WriteByte(s[i])
}
}

if escaped {
current.WriteByte('\\')
}

result = append(result, strings.TrimSpace(current.String()))
return result
}
2 changes: 1 addition & 1 deletion pkg/jira/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
96 changes: 96 additions & 0 deletions pkg/jira/edit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}