From 3df8f3847ecf5dda03b278f436c2a93f9e87a3e3 Mon Sep 17 00:00:00 2001 From: Research Date: Mon, 9 Feb 2026 15:11:23 -0500 Subject: [PATCH] feat: add support for user-type custom fields Add handling for user-type custom fields in both create and edit paths. Previously, user-type fields passed via --custom were silently dropped because the code only handled option, array, number, project, and string types. User fields require {"accountId":"..."} for Cloud or {"name":"..."} for Server/DC installations. Changes: - Add customFieldTypeUser and customFieldTypeUserSet types - Add newCustomFieldTypeUser() constructor for Cloud vs Local - Handle single-user and multi-user array fields in create and edit - Pass installationType through to constructCustomFields - Convert if/else chains to switch (gocritic lint) - Add comprehensive unit tests (18 new tests) Fixes #798, #758, #579 --- internal/cmd/issue/edit/edit.go | 1 + pkg/jira/create.go | 19 +++- pkg/jira/create_test.go | 158 ++++++++++++++++++++++++++ pkg/jira/customfield.go | 18 +++ pkg/jira/customfield_test.go | 34 ++++++ pkg/jira/edit.go | 23 +++- pkg/jira/edit_test.go | 189 ++++++++++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 9 deletions(-) create mode 100644 pkg/jira/customfield_test.go create mode 100644 pkg/jira/edit_test.go diff --git a/internal/cmd/issue/edit/edit.go b/internal/cmd/issue/edit/edit.go index 5dfa5d97..b827d904 100644 --- a/internal/cmd/issue/edit/edit.go +++ b/internal/cmd/issue/edit/edit.go @@ -163,6 +163,7 @@ func edit(cmd *cobra.Command, args []string) { cmdcommon.ValidateCustomFields(edr.CustomFields, configuredCustomFields) edr.WithCustomFields(configuredCustomFields) } + edr.ForInstallationType(viper.GetString("installation")) return client.Edit(params.issueKey, &edr) }() diff --git a/pkg/jira/create.go b/pkg/jira/create.go index bec14918..6fbd59e3 100644 --- a/pkg/jira/create.go +++ b/pkg/jira/create.go @@ -56,7 +56,7 @@ func (cr *CreateRequest) ForProjectType(pt string) { cr.projectType = pt } -// ForInstallationType sets jira project type. +// ForInstallationType sets jira installation type for the create request. func (cr *CreateRequest) ForInstallationType(it string) { cr.installationType = it } @@ -222,12 +222,12 @@ func (*Client) getRequestData(req *CreateRequest) *createRequest { }{OriginalEstimate: req.OriginalEstimate} } - constructCustomFields(req.CustomFields, req.configuredCustomFields, &data) + constructCustomFields(req.CustomFields, req.configuredCustomFields, req.installationType, &data) return &data } -func constructCustomFields(fields map[string]string, configuredFields []IssueTypeField, data *createRequest) { +func constructCustomFields(fields map[string]string, configuredFields []IssueTypeField, installationType string, data *createRequest) { if len(fields) == 0 || len(configuredFields) == 0 { return } @@ -248,13 +248,20 @@ func constructCustomFields(fields map[string]string, configuredFields []IssueTyp data.Fields.M.customFields[configured.Key] = customFieldTypeProject{Value: val} case customFieldFormatArray: pieces := strings.Split(strings.TrimSpace(val), ",") - if configured.Schema.Items == customFieldFormatOption { + switch configured.Schema.Items { + case customFieldFormatOption: items := make([]customFieldTypeOption, 0) for _, p := range pieces { items = append(items, customFieldTypeOption{Value: p}) } data.Fields.M.customFields[configured.Key] = items - } else { + case customFieldFormatUser: + items := make([]customFieldTypeUser, 0, len(pieces)) + for _, p := range pieces { + items = append(items, newCustomFieldTypeUser(strings.TrimSpace(p), installationType)) + } + data.Fields.M.customFields[configured.Key] = items + default: data.Fields.M.customFields[configured.Key] = pieces } case customFieldFormatNumber: @@ -265,6 +272,8 @@ func constructCustomFields(fields map[string]string, configuredFields []IssueTyp } else { data.Fields.M.customFields[configured.Key] = customFieldTypeNumber(num) } + case customFieldFormatUser: + data.Fields.M.customFields[configured.Key] = newCustomFieldTypeUser(val, installationType) default: data.Fields.M.customFields[configured.Key] = val } diff --git a/pkg/jira/create_test.go b/pkg/jira/create_test.go index fab756c4..7dbbbe82 100644 --- a/pkg/jira/create_test.go +++ b/pkg/jira/create_test.go @@ -1,6 +1,7 @@ package jira import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -178,3 +179,160 @@ func TestCreateEpicNextGen(t *testing.T) { _, err = client.CreateV2(&requestData) assert.Error(t, &ErrUnexpectedResponse{}, err) } + +func TestCreateWithUserCustomField(t *testing.T) { + expectedBody := `{"update":{},"fields":{"project":{"key":"TEST"},"issuetype":{"name":"Bug"},` + + `"summary":"Test bug","customfield_12574":{"accountId":"5f7e1b2c"}}}` + testServer := createTestServer{code: 201} + server := testServer.serve(t, expectedBody) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + requestData := CreateRequest{ + Project: "TEST", + IssueType: "Bug", + Summary: "Test bug", + CustomFields: map[string]string{ + "pm-owner": "5f7e1b2c", + }, + } + requestData.ForInstallationType(InstallationTypeCloud) + requestData.WithCustomFields([]IssueTypeField{ + { + Name: "PM owner", + Key: "customfield_12574", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "user"}, + }, + }) + + actual, err := client.CreateV2(&requestData) + assert.NoError(t, err) + + expected := &CreateResponse{ + ID: "10057", + Key: "TEST-3", + } + assert.Equal(t, expected, actual) +} + +func TestCreateWithMultiUserCustomField(t *testing.T) { + expectedBody := `{"update":{},"fields":{"project":{"key":"TEST"},"issuetype":{"name":"Bug"},` + + `"summary":"Test bug","customfield_10100":[{"accountId":"user1"},{"accountId":"user2"}]}}` + testServer := createTestServer{code: 201} + server := testServer.serve(t, expectedBody) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + requestData := CreateRequest{ + Project: "TEST", + IssueType: "Bug", + Summary: "Test bug", + CustomFields: map[string]string{ + "reviewers": "user1,user2", + }, + } + requestData.ForInstallationType(InstallationTypeCloud) + requestData.WithCustomFields([]IssueTypeField{ + { + Name: "Reviewers", + Key: "customfield_10100", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "array", Items: "user"}, + }, + }) + + actual, err := client.CreateV2(&requestData) + assert.NoError(t, err) + + expected := &CreateResponse{ + ID: "10057", + Key: "TEST-3", + } + assert.Equal(t, expected, actual) +} + +func TestConstructCustomFieldsUser(t *testing.T) { + data := &createRequest{} + + fields := map[string]string{ + "pm-owner": "5f7e1b2c", + } + configuredFields := []IssueTypeField{ + { + Name: "PM owner", + Key: "customfield_12574", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "user"}, + }, + } + + constructCustomFields(fields, configuredFields, InstallationTypeCloud, data) + + result, ok := data.Fields.M.customFields["customfield_12574"] + assert.True(t, ok) + + b, err := json.Marshal(result) + assert.NoError(t, err) + assert.JSONEq(t, `{"accountId":"5f7e1b2c"}`, string(b)) +} + +func TestConstructCustomFieldsMultiUser(t *testing.T) { + data := &createRequest{} + + fields := map[string]string{ + "reviewers": "user1,user2", + } + configuredFields := []IssueTypeField{ + { + Name: "Reviewers", + Key: "customfield_10100", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "array", Items: "user"}, + }, + } + + constructCustomFields(fields, configuredFields, InstallationTypeCloud, data) + + result, ok := data.Fields.M.customFields["customfield_10100"] + assert.True(t, ok) + + b, err := json.Marshal(result) + assert.NoError(t, err) + assert.JSONEq(t, `[{"accountId":"user1"},{"accountId":"user2"}]`, string(b)) +} + +func TestConstructCustomFieldsUserLocal(t *testing.T) { + data := &createRequest{} + + fields := map[string]string{ + "pm-owner": "john.doe", + } + configuredFields := []IssueTypeField{ + { + Name: "PM owner", + Key: "customfield_12574", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "user"}, + }, + } + + constructCustomFields(fields, configuredFields, InstallationTypeLocal, data) + + result, ok := data.Fields.M.customFields["customfield_12574"] + assert.True(t, ok) + + b, err := json.Marshal(result) + assert.NoError(t, err) + assert.JSONEq(t, `{"name":"john.doe"}`, string(b)) +} diff --git a/pkg/jira/customfield.go b/pkg/jira/customfield.go index 92f3c675..bedf3b26 100644 --- a/pkg/jira/customfield.go +++ b/pkg/jira/customfield.go @@ -5,6 +5,7 @@ const ( customFieldFormatArray = "array" customFieldFormatNumber = "number" customFieldFormatProject = "project" + customFieldFormatUser = "user" ) type customField map[string]interface{} @@ -39,3 +40,20 @@ type customFieldTypeProject struct { type customFieldTypeProjectSet struct { Set customFieldTypeProject `json:"set"` } + +type customFieldTypeUser struct { + Name *string `json:"name,omitempty"` // For local (Server/DC) installation. + AccountID *string `json:"accountId,omitempty"` // For cloud installation. +} + +type customFieldTypeUserSet struct { + Set customFieldTypeUser `json:"set"` +} + +// newCustomFieldTypeUser creates a user field value appropriate for the installation type. +func newCustomFieldTypeUser(val, installationType string) customFieldTypeUser { + if installationType == InstallationTypeLocal { + return customFieldTypeUser{Name: &val} + } + return customFieldTypeUser{AccountID: &val} +} diff --git a/pkg/jira/customfield_test.go b/pkg/jira/customfield_test.go new file mode 100644 index 00000000..dd308c48 --- /dev/null +++ b/pkg/jira/customfield_test.go @@ -0,0 +1,34 @@ +package jira + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCustomFieldTypeUserCloud(t *testing.T) { + accountID := "5f7e1b2c3d4e5f6a7b8c9d0e" + user := newCustomFieldTypeUser(accountID, InstallationTypeCloud) + + assert.Nil(t, user.Name) + assert.NotNil(t, user.AccountID) + assert.Equal(t, accountID, *user.AccountID) +} + +func TestNewCustomFieldTypeUserLocal(t *testing.T) { + username := "john.doe" + user := newCustomFieldTypeUser(username, InstallationTypeLocal) + + assert.NotNil(t, user.Name) + assert.Nil(t, user.AccountID) + assert.Equal(t, username, *user.Name) +} + +func TestNewCustomFieldTypeUserDefaultIsCloud(t *testing.T) { + accountID := "5f7e1b2c3d4e5f6a7b8c9d0e" + user := newCustomFieldTypeUser(accountID, "") + + assert.Nil(t, user.Name) + assert.NotNil(t, user.AccountID) + assert.Equal(t, accountID, *user.AccountID) +} diff --git a/pkg/jira/edit.go b/pkg/jira/edit.go index a8e4bcd2..83c35c97 100644 --- a/pkg/jira/edit.go +++ b/pkg/jira/edit.go @@ -36,6 +36,7 @@ type EditRequest struct { SkipNotify bool configuredCustomFields []IssueTypeField + installationType string } // WithCustomFields sets valid custom fields for the issue. @@ -43,6 +44,11 @@ func (er *EditRequest) WithCustomFields(cf []IssueTypeField) { er.configuredCustomFields = cf } +// ForInstallationType sets jira installation type for the edit request. +func (er *EditRequest) ForInstallationType(it string) { + er.installationType = it +} + // Edit updates an issue using POST /issue endpoint. func (c *Client) Edit(key string, req *EditRequest) error { data := getRequestDataForEdit(req) @@ -353,12 +359,12 @@ func getRequestDataForEdit(req *EditRequest) *editRequest { Update: update, Fields: fields, } - constructCustomFieldsForEdit(req.CustomFields, req.configuredCustomFields, &data) + constructCustomFieldsForEdit(req.CustomFields, req.configuredCustomFields, req.installationType, &data) return &data } -func constructCustomFieldsForEdit(fields map[string]string, configuredFields []IssueTypeField, data *editRequest) { +func constructCustomFieldsForEdit(fields map[string]string, configuredFields []IssueTypeField, installationType string, data *editRequest) { if len(fields) == 0 || len(configuredFields) == 0 { return } @@ -379,7 +385,8 @@ func constructCustomFieldsForEdit(fields map[string]string, configuredFields []I data.Update.M.customFields[configured.Key] = []customFieldTypeProjectSet{{Set: customFieldTypeProject{Value: val}}} case customFieldFormatArray: pieces := strings.Split(strings.TrimSpace(val), ",") - if configured.Schema.Items == customFieldFormatOption { + switch configured.Schema.Items { + case customFieldFormatOption: items := make([]customFieldTypeOptionAddRemove, 0) for _, p := range pieces { if strings.HasPrefix(p, separatorMinus) { @@ -389,7 +396,13 @@ func constructCustomFieldsForEdit(fields map[string]string, configuredFields []I } } data.Update.M.customFields[configured.Key] = items - } else { + case customFieldFormatUser: + items := make([]customFieldTypeUserSet, 0, len(pieces)) + for _, p := range pieces { + items = append(items, customFieldTypeUserSet{Set: newCustomFieldTypeUser(strings.TrimSpace(p), installationType)}) + } + data.Update.M.customFields[configured.Key] = items + default: data.Update.M.customFields[configured.Key] = pieces } case customFieldFormatNumber: @@ -400,6 +413,8 @@ func constructCustomFieldsForEdit(fields map[string]string, configuredFields []I } else { data.Update.M.customFields[configured.Key] = []customFieldTypeNumberSet{{Set: customFieldTypeNumber(num)}} } + case customFieldFormatUser: + data.Update.M.customFields[configured.Key] = []customFieldTypeUserSet{{Set: newCustomFieldTypeUser(val, installationType)}} default: data.Update.M.customFields[configured.Key] = []customFieldTypeStringSet{{Set: val}} } diff --git a/pkg/jira/edit_test.go b/pkg/jira/edit_test.go new file mode 100644 index 00000000..4b735d62 --- /dev/null +++ b/pkg/jira/edit_test.go @@ -0,0 +1,189 @@ +package jira + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEditWithUserCustomField(t *testing.T) { + expectedBody := `{"update":{"customfield_12574":[{"set":{"accountId":"5f7e1b2c"}}]},"fields":{"parent":{}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path) + assert.Equal(t, "PUT", r.Method) + + actualBody := new(strings.Builder) + _, _ = io.Copy(actualBody, r.Body) + + assert.JSONEq(t, expectedBody, actualBody.String()) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + edr := EditRequest{ + CustomFields: map[string]string{ + "pm-owner": "5f7e1b2c", + }, + } + edr.WithCustomFields([]IssueTypeField{ + { + Name: "PM owner", + Key: "customfield_12574", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "user"}, + }, + }) + edr.ForInstallationType(InstallationTypeCloud) + + err := client.Edit("TEST-1", &edr) + assert.NoError(t, err) +} + +func TestEditWithUserCustomFieldLocal(t *testing.T) { + expectedBody := `{"update":{"customfield_12574":[{"set":{"name":"john.doe"}}]},"fields":{"parent":{}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path) + + actualBody := new(strings.Builder) + _, _ = io.Copy(actualBody, r.Body) + + assert.JSONEq(t, expectedBody, actualBody.String()) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + edr := EditRequest{ + CustomFields: map[string]string{ + "pm-owner": "john.doe", + }, + } + edr.WithCustomFields([]IssueTypeField{ + { + Name: "PM owner", + Key: "customfield_12574", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "user"}, + }, + }) + edr.ForInstallationType(InstallationTypeLocal) + + err := client.Edit("TEST-1", &edr) + assert.NoError(t, err) +} + +func TestEditWithMultiUserCustomField(t *testing.T) { + expectedBody := `{"update":{"customfield_10100":[{"set":{"accountId":"user1"}},{"set":{"accountId":"user2"}}]},"fields":{"parent":{}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1", r.URL.Path) + + actualBody := new(strings.Builder) + _, _ = io.Copy(actualBody, r.Body) + + assert.JSONEq(t, expectedBody, actualBody.String()) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + edr := EditRequest{ + CustomFields: map[string]string{ + "reviewers": "user1,user2", + }, + } + edr.WithCustomFields([]IssueTypeField{ + { + Name: "Reviewers", + Key: "customfield_10100", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "array", Items: "user"}, + }, + }) + edr.ForInstallationType(InstallationTypeCloud) + + err := client.Edit("TEST-1", &edr) + assert.NoError(t, err) +} + +func TestConstructCustomFieldsForEditUser(t *testing.T) { + data := &editRequest{} + + fields := map[string]string{ + "pm-owner": "5f7e1b2c", + } + configuredFields := []IssueTypeField{ + { + Name: "PM owner", + Key: "customfield_12574", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "user"}, + }, + } + + constructCustomFieldsForEdit(fields, configuredFields, InstallationTypeCloud, data) + + result, ok := data.Update.M.customFields["customfield_12574"] + assert.True(t, ok) + + b, err := json.Marshal(result) + assert.NoError(t, err) + assert.JSONEq(t, `[{"set":{"accountId":"5f7e1b2c"}}]`, string(b)) +} + +func TestConstructCustomFieldsForEditMultiUser(t *testing.T) { + data := &editRequest{} + + fields := map[string]string{ + "reviewers": "user1,user2", + } + configuredFields := []IssueTypeField{ + { + Name: "Reviewers", + Key: "customfield_10100", + Schema: struct { + DataType string `json:"type"` + Items string `json:"items,omitempty"` + }{DataType: "array", Items: "user"}, + }, + } + + constructCustomFieldsForEdit(fields, configuredFields, InstallationTypeCloud, data) + + result, ok := data.Update.M.customFields["customfield_10100"] + assert.True(t, ok) + + b, err := json.Marshal(result) + assert.NoError(t, err) + assert.JSONEq(t, `[{"set":{"accountId":"user1"}},{"set":{"accountId":"user2"}}]`, string(b)) +} + +func TestConstructCustomFieldsForEditEmpty(t *testing.T) { + data := &editRequest{} + + constructCustomFieldsForEdit(nil, nil, InstallationTypeCloud, data) + assert.Nil(t, data.Update.M.customFields) + + constructCustomFieldsForEdit(map[string]string{}, []IssueTypeField{}, InstallationTypeCloud, data) + assert.Nil(t, data.Update.M.customFields) +}