Repository: amp-labs/connectors
Pull request #1411: [CON-1527]feat(zendesk): Human-readable custom fields
Author: login: Cobalt0s
Created: 2025-02-27T01:30:15Z | Merged: 2025-04-18T20:28:54Z

# Description
`Requests` and `Tickets` objects share the same `ticket_fields` as its custom fields.

The id is mapped to `ticket_fields` to figure out the human readable name. However, there is no good JSON type field name. Ex: "customer type", which contains a space because field name is "Customer Type".
![image](https://github.com/user-attachments/assets/99bae26a-cb7e-4fa7-90ed-39a87cf4b2a8)

Custom fields have interesting value options which are either stored under one of the following:
![image](https://github.com/user-attachments/assets/8c40ca56-5115-499e-8a53-f1ab1333e783)

There is no good documentation on the types of custom fields. Here is the approach I took to map field types to Ampersand's `ValueType`:
![image](https://github.com/user-attachments/assets/0c3c1f05-92da-4557-a40d-eebc61103540)

# Test
## Metadata(Tickets)
![image](https://github.com/user-attachments/assets/ece80ab4-2e24-4c75-a793-f8d198898019)
![image](https://github.com/user-attachments/assets/954e970a-07f7-4580-a9e6-56eee1c3b0f0)
## Read(Tickets)
### Before
![image](https://github.com/user-attachments/assets/1788188b-6b1e-4f95-b7de-2e8c6df2a06e)
### After
Customer Type is a custom field and appears only 25 times nested under `fields`. If it was under `raw` it would be 50. Therefore, raw is uneffected. This is achieved by using `common.MakeMarshaledDataFunc` which is used by connectors where `fields =/= raw`.
![image](https://github.com/user-attachments/assets/208fde69-4eed-4c4a-8dbc-70f723c7bc57)
### Mapping
Only one API call to get custom fields is made and then is applied for each record, respecting performence.
![image](https://github.com/user-attachments/assets/b82a85ac-fb9a-4210-8b1f-162e321f148f)




Diff:
diff --git a/common/types.go b/common/types.go
index cd1cab679d..eff251dfff 100644
--- a/common/types.go
+++ b/common/types.go
@@ -113,6 +113,9 @@ var (
 	// ErrPayloadNotURLForm is returned when payload is not string key-value pair
 	// which could be encoded for POST with content type of application/x-www-form-urlencoded.
 	ErrPayloadNotURLForm = errors.New("payload cannot be url-form encoded")
+
+	// ErrResolvingCustomFields is returned when custom fields cannot be retrieved for Read or ListObjectMetadata.
+	ErrResolvingCustomFields = errors.New("cannot resolve custom fields")
 )
 
 // ReadParams defines how we are reading data from a SaaS API.
@@ -333,6 +336,12 @@ type ObjectMetadata struct {
 	FieldsMap map[string]string
 }
 
+// AddFieldMetadata updates Fields and FieldsMap fields ensuring data consistency.
+func (m *ObjectMetadata) AddFieldMetadata(fieldName string, fieldMetadata FieldMetadata) {
+	m.Fields[fieldName] = fieldMetadata
+	m.FieldsMap[fieldName] = fieldMetadata.DisplayName
+}
+
 // NewObjectMetadata constructs ObjectMetadata.
 // This will automatically infer fields map from field metadata map. This construct exists for such convenience.
 func NewObjectMetadata(displayName string, fields map[string]FieldMetadata) *ObjectMetadata {
diff --git a/internal/datautils/set.go b/internal/datautils/set.go
index 93cd6ae4b9..1ece6d4291 100644
--- a/internal/datautils/set.go
+++ b/internal/datautils/set.go
@@ -69,6 +69,28 @@ func (s Set[V]) Subtract(other Set[V]) []V {
 	return difference
 }
 
+func (s Set[V]) Intersection(other Set[V]) []V {
+	var (
+		smallest     = s // assume `s` is smaller than `other`
+		largest      = other
+		intersection = make([]V, 0)
+	)
+
+	// Create intersection using the smallest set, to speed up the iteration.
+	if len(other) < len(s) {
+		smallest = other
+		largest = s
+	}
+
+	for v := range smallest {
+		if _, ok := largest[v]; ok {
+			intersection = append(intersection, v)
+		}
+	}
+
+	return intersection
+}
+
 // Has returns true if key is found in the set.
 func (s Set[T]) Has(key T) bool {
 	_, ok := s[key]
diff --git a/providers/keap/metadata_test.go b/providers/keap/metadata_test.go
index e980aa8ae5..9e245bfe51 100644
--- a/providers/keap/metadata_test.go
+++ b/providers/keap/metadata_test.go
@@ -197,8 +197,8 @@ func TestListObjectMetadataV2(t *testing.T) { // nolint:funlen,gocognit,cyclop
 
 func metadataExpectAbsentFields(serverURL string, actual, expected *common.ListObjectMetadataResult) bool {
 	const contacts = "contacts"
-	if !errors.Is(actual.Errors[contacts], ErrResolvingCustomFields) {
-		slog.Info("missing metadata error", "errors", ErrResolvingCustomFields)
+	if !errors.Is(actual.Errors[contacts], common.ErrResolvingCustomFields) {
+		slog.Info("missing metadata error", "errors", common.ErrResolvingCustomFields)
 
 		return false
 	}
diff --git a/providers/keap/read.go b/providers/keap/read.go
index 5de52b0d3c..6162d76db5 100644
--- a/providers/keap/read.go
+++ b/providers/keap/read.go
@@ -12,8 +12,6 @@ import (
 	"github.com/amp-labs/connectors/providers/keap/metadata"
 )
 
-var ErrResolvingCustomFields = errors.New("cannot resolve custom fields")
-
 func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common.ReadResult, error) {
 	if err := config.ValidateParams(true); err != nil {
 		return nil, err
@@ -98,17 +96,17 @@ func (c *Connector) requestCustomFields(
 
 	url, err := c.getURL(modulePath, objectName, "model")
 	if err != nil {
-		return nil, errors.Join(ErrResolvingCustomFields, err)
+		return nil, errors.Join(common.ErrResolvingCustomFields, err)
 	}
 
 	res, err := c.Client.Get(ctx, url.String())
 	if err != nil {
-		return nil, errors.Join(ErrResolvingCustomFields, err)
+		return nil, errors.Join(common.ErrResolvingCustomFields, err)
 	}
 
 	fieldsResponse, err := common.UnmarshalJSON[modelCustomFieldsResponse](res)
 	if err != nil {
-		return nil, errors.Join(ErrResolvingCustomFields, err)
+		return nil, errors.Join(common.ErrResolvingCustomFields, err)
 	}
 
 	fields := make(map[int]modelCustomField)
diff --git a/providers/zendesksupport/customFields.go b/providers/zendesksupport/customFields.go
new file mode 100644
index 0000000000..960d1de3f6
--- /dev/null
+++ b/providers/zendesksupport/customFields.go
@@ -0,0 +1,202 @@
+package zendesksupport
+
+import (
+	"context"
+	"errors"
+	"strconv"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/spyzhov/ajson"
+)
+
+// requestCustomTicketFields makes and API call to get model describing custom fields.
+// For not applicable objects the empty mapping is returned.
+// The mapping is between "custom field id" and struct containing "human-readable field name".
+//
+// Custom fields are always associated with "ticket_fields" regardless of the object type.
+func (c *Connector) requestCustomTicketFields(
+	ctx context.Context, objectName string,
+) (map[int64]ticketField, error) {
+	if !objectsWithCustomFields[c.Module.ID].Has(objectName) {
+		// This object doesn't have custom fields, we are done.
+		return map[int64]ticketField{}, nil
+	}
+
+	return c.fetchCustomTicketFields(ctx)
+}
+
+func (c *Connector) fetchCustomTicketFields(ctx context.Context) (map[int64]ticketField, error) {
+	url, err := c.getReadURL("ticket_fields")
+	if err != nil {
+		return nil, errors.Join(common.ErrResolvingCustomFields, err)
+	}
+
+	res, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return nil, errors.Join(common.ErrResolvingCustomFields, err)
+	}
+
+	fieldsResponse, err := common.UnmarshalJSON[ticketFieldsResponse](res)
+	if err != nil {
+		return nil, errors.Join(common.ErrResolvingCustomFields, err)
+	}
+
+	fields := make(map[int64]ticketField)
+	for _, field := range fieldsResponse.TicketFields {
+		fields[field.ID] = field
+	}
+
+	return fields, nil
+}
+
+// nolint:tagliatelle
+// https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_fields/#json-format
+type ticketFieldsResponse struct {
+	TicketFields []ticketField `json:"ticket_fields"`
+}
+
+// nolint:tagliatelle
+type ticketField struct {
+	// Automatically assigned when created
+	ID int64 `json:"id"`
+	// System or custom field type.
+	Type string `json:"type"`
+	// The title of the ticket field for end users in Help Center
+	TitleInPortal string `json:"title_in_portal"`
+	// Title is usually similar to the TitleInPortal.
+	Title string `json:"title"`
+
+	// Presented for a system ticket field of type "tickettype", "priority" or "status".
+	SystemFieldOptions []systemFieldOption `json:"system_field_options,omitempty"`
+	// List of customized ticket statuses. Only presented for a system ticket field of type "custom_status".
+	CustomStatuses []customStatus `json:"custom_statuses,omitempty"`
+	// Required and presented for a custom ticket field of type "multiselect" or "tagger".
+	CustomFieldOptions []customFieldOption `json:"custom_field_options,omitempty"`
+}
+
+func (f ticketField) GetValueType() common.ValueType {
+	switch f.Type {
+	case "subject", "description":
+		return common.ValueTypeString
+	case
+		// custom_field_options:
+		"tagger",
+		// system_field_options:
+		"tickettype", "priority", "status",
+		// custom_statuses:
+		"custom_status":
+		return common.ValueTypeSingleSelect
+	case
+		// custom_field_options:
+		"multiselect":
+		return common.ValueTypeMultiSelect
+	default:
+		// group, assignee
+		return common.ValueTypeOther
+	}
+}
+
+func (f ticketField) getValues() []common.FieldValue {
+	result := make([]common.FieldValue, 0)
+
+	for _, option := range f.SystemFieldOptions {
+		result = append(result, common.FieldValue{
+			Value:        option.Value,
+			DisplayValue: option.Name,
+		})
+	}
+
+	for _, option := range f.CustomFieldOptions {
+		result = append(result, common.FieldValue{
+			Value:        option.Value,
+			DisplayValue: option.Name,
+		})
+	}
+
+	for _, status := range f.CustomStatuses {
+		result = append(result, common.FieldValue{
+			Value:        strconv.FormatInt(status.ID, 10),
+			DisplayValue: status.AgentLabel,
+		})
+	}
+
+	return result
+}
+
+// nolint:tagliatelle
+// https://developer.zendesk.com/api-reference/ticketing/tickets/custom_ticket_statuses/#json-format
+type customStatus struct {
+	ID int64 `json:"id"`
+	// The label displayed to agents. Maximum length is 48 characters
+	AgentLabel string `json:"agent_label"`
+}
+
+// https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_fields/#list-ticket-field-options
+type customFieldOption struct {
+	ID    int64  `json:"id"`
+	Name  string `json:"name"`
+	Value string `json:"value"`
+}
+
+// https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_fields/#list-ticket-field-options
+type systemFieldOption struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
+}
+
+// nolint:tagliatelle,lll
+// https://developer.zendesk.com/documentation/ticketing/managing-tickets/creating-and-updating-tickets/#setting-custom-field-values
+type readCustomFieldsResponse struct {
+	CustomFields []readCustomField `json:"custom_fields"`
+}
+
+type readCustomField struct {
+	ID    int64 `json:"id"`
+	Value any   `json:"value"`
+}
+
+// Before parsing the records, if any custom fields are present (without a human-readable name),
+// this will call the correct API to extend & replace the custom field with human-readable information.
+// Object will then be enhanced using model.
+func (c *Connector) attachReadCustomFields(
+	customFields map[int64]ticketField,
+) common.RecordTransformer {
+	return func(node *ajson.Node) (map[string]any, error) {
+		if len(customFields) == 0 {
+			// No custom fields, no-op, return as is.
+			return jsonquery.Convertor.ObjectToMap(node)
+		}
+
+		return enhanceObjectsWithCustomFieldNames(node, customFields)
+	}
+}
+
+// In general this does the usual JSON parsing.
+// However, those objects that contain "custom_fields" are processed as follows:
+// * Locate custom fields in JSON read response.
+// * Replace ids with human-readable names, which is provided as argument.
+// * Place fields at the top level of the object.
+func enhanceObjectsWithCustomFieldNames(
+	node *ajson.Node,
+	fields map[int64]ticketField,
+) (map[string]any, error) {
+	object, err := jsonquery.Convertor.ObjectToMap(node)
+	if err != nil {
+		return nil, err
+	}
+
+	customFieldsResponse, err := jsonquery.ParseNode[readCustomFieldsResponse](node)
+	if err != nil {
+		return nil, err
+	}
+
+	// Replace identifiers with human-readable field names which were found by making a call to "/model".
+	for _, field := range customFieldsResponse.CustomFields {
+		if model, ok := fields[field.ID]; ok {
+			object[model.Title] = field.Value
+		}
+	}
+
+	return object, nil
+}
diff --git a/providers/zendesksupport/metadata.go b/providers/zendesksupport/metadata.go
index d5c93df246..0fc05ab91b 100644
--- a/providers/zendesksupport/metadata.go
+++ b/providers/zendesksupport/metadata.go
@@ -4,11 +4,46 @@ import (
 	"context"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/internal/datautils"
 	"github.com/amp-labs/connectors/providers/zendesksupport/metadata"
 )
 
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
-	return metadata.Schemas.Select(c.Module.ID, objectNames)
+	metadataResult, err := metadata.Schemas.Select(c.Module.ID, objectNames)
+	if err != nil {
+		return nil, err
+	}
+
+	customObjectNames := objectsWithCustomFields[c.Module.ID].Intersection(datautils.NewSetFromList(objectNames))
+	if len(customObjectNames) == 0 {
+		return metadataResult, nil
+	}
+
+	// Custom fields are shared across each customObject, therefore make only 1 API call.
+	// Each custom object will either get new fields or an Error field will be set.
+	fields, err := c.fetchCustomTicketFields(ctx)
+
+	for _, objectName := range customObjectNames {
+		if err != nil {
+			metadataResult.Errors[objectName] = err
+
+			continue
+		}
+
+		// Attach fields to the object metadata.
+		objectMetadata := metadataResult.Result[objectName]
+		for _, field := range fields {
+			objectMetadata.AddFieldMetadata(field.Title, common.FieldMetadata{
+				DisplayName:  field.TitleInPortal,
+				ValueType:    field.GetValueType(),
+				ProviderType: field.Type,
+				ReadOnly:     false,
+				Values:       field.getValues(),
+			})
+		}
+	}
+
+	return metadataResult, nil
 }
diff --git a/providers/zendesksupport/metadata_test.go b/providers/zendesksupport/metadata_test.go
index 351188b2f8..0d1e6822a7 100644
--- a/providers/zendesksupport/metadata_test.go
+++ b/providers/zendesksupport/metadata_test.go
@@ -8,13 +8,17 @@ import (
 	"github.com/amp-labs/connectors"
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockcond"
 	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
 	"github.com/amp-labs/connectors/test/utils/testroutines"
+	"github.com/amp-labs/connectors/test/utils/testutils"
 )
 
 func TestListObjectMetadataZendeskSupportModule(t *testing.T) { // nolint:funlen,gocognit,cyclop
 	t.Parallel()
 
+	responseTicketsCustomFields := testutils.DataFromFile(t, "read/custom_fields/ticket_fields.json")
+
 	tests := []testroutines.Metadata{
 		{
 			Name:         "At least one object name must be queried",
@@ -94,6 +98,72 @@ func TestListObjectMetadataZendeskSupportModule(t *testing.T) { // nolint:funlen
 			},
 			ExpectedErrs: nil,
 		},
+		{
+			Name:  "Successfully describe ticket custom fields",
+			Input: []string{"tickets"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.PathSuffix("/api/v2/ticket_fields"),
+				Then:  mockserver.Response(http.StatusOK, responseTicketsCustomFields),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"tickets": {
+						DisplayName: "Tickets",
+						Fields: map[string]common.FieldMetadata{
+							"comment": {
+								DisplayName:  "comment",
+								ValueType:    "other",
+								ProviderType: "object",
+								ReadOnly:     false,
+							},
+							"priority": {
+								DisplayName:  "priority",
+								ValueType:    "singleSelect",
+								ProviderType: "string",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "urgent",
+									DisplayValue: "urgent",
+								}, {
+									Value:        "high",
+									DisplayValue: "high",
+								}, {
+									Value:        "normal",
+									DisplayValue: "normal",
+								}, {
+									Value:        "low",
+									DisplayValue: "low",
+								}},
+							},
+							// Custom field
+							"Customer Type": {
+								DisplayName:  "Customer Type",
+								ValueType:    "singleSelect",
+								ProviderType: "tagger",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "vip_customer",
+									DisplayValue: "VIP Customer",
+								}, {
+									Value:        "standard_customer",
+									DisplayValue: "Standard Customer",
+								}},
+							},
+						},
+						FieldsMap: map[string]string{
+							"comment": "comment",
+							// Custom fields
+							"Customer Type": "Customer Type",
+							"Topic":         "Topic",
+						},
+					},
+				},
+				Errors: make(map[string]error),
+			},
+			ExpectedErrs: nil,
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/providers/zendesksupport/objectNames.go b/providers/zendesksupport/objectNames.go
index cb22cb4e41..3e446f183d 100644
--- a/providers/zendesksupport/objectNames.go
+++ b/providers/zendesksupport/objectNames.go
@@ -7,6 +7,11 @@ import (
 	"github.com/amp-labs/connectors/providers/zendesksupport/metadata"
 )
 
+const (
+	objectNameTickets  = "tickets"
+	objectNameRequests = "requests"
+)
+
 // Supported object names can be found under schemas.json.
 var supportedObjectsByRead = metadata.Schemas.ObjectNames() //nolint:gochecknoglobals
 
@@ -29,3 +34,12 @@ var writeURLExceptions = map[common.ModuleID]datautils.Map[string, string]{ //no
 	},
 	providers.ModuleZendeskHelpCenter: {},
 }
+
+var objectsWithCustomFields = map[common.ModuleID]datautils.StringSet{ // nolint:gochecknoglobals
+	ModuleTicketing: datautils.NewStringSet(
+		// https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#json-format
+		objectNameTickets,
+		// https://developer.zendesk.com/api-reference/ticketing/tickets/ticket-requests/#json-format
+		objectNameRequests,
+	),
+}
diff --git a/providers/zendesksupport/parse.go b/providers/zendesksupport/parse.go
index 5e6aac5954..3188aadd10 100644
--- a/providers/zendesksupport/parse.go
+++ b/providers/zendesksupport/parse.go
@@ -1,7 +1,9 @@
 package zendesksupport
 
 import (
+	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/jsonquery"
+	"github.com/amp-labs/connectors/providers/zendesksupport/metadata"
 	"github.com/spyzhov/ajson"
 )
 
@@ -49,3 +51,11 @@ func getNextRecordsURL(node *ajson.Node) (string, error) {
 
 	return jsonquery.New(node).StrWithDefault("after_url", "")
 }
+
+func getRecords(moduleID common.ModuleID, objectName string) common.NodeRecordsFunc {
+	return func(node *ajson.Node) ([]*ajson.Node, error) {
+		responseFieldName := metadata.Schemas.LookupArrayFieldName(moduleID, objectName)
+
+		return jsonquery.New(node).ArrayRequired(responseFieldName)
+	}
+}
diff --git a/providers/zendesksupport/read.go b/providers/zendesksupport/read.go
index ccc1215154..05bf23ac6a 100644
--- a/providers/zendesksupport/read.go
+++ b/providers/zendesksupport/read.go
@@ -29,13 +29,16 @@ func (c *Connector) Read(ctx context.Context, config common.ReadParams) (*common
 		return nil, err
 	}
 
-	responseFieldName := metadata.Schemas.LookupArrayFieldName(c.Module.ID, config.ObjectName)
+	customFields, err := c.requestCustomTicketFields(ctx, config.ObjectName)
+	if err != nil {
+		return nil, err
+	}
 
 	return common.ParseResult(
 		rsp,
-		common.ExtractRecordsFromPath(responseFieldName),
+		getRecords(c.Module.ID, config.ObjectName),
 		getNextRecordsURL,
-		common.GetMarshaledData,
+		common.MakeMarshaledDataFunc(c.attachReadCustomFields(customFields)),
 		config.Fields,
 	)
 }
diff --git a/providers/zendesksupport/read_test.go b/providers/zendesksupport/read_test.go
index 6a20800ba0..cca80c39c1 100644
--- a/providers/zendesksupport/read_test.go
+++ b/providers/zendesksupport/read_test.go
@@ -138,32 +138,54 @@ func TestIncrementalReadZendeskSupportModule(t *testing.T) { //nolint:funlen,goc
 	t.Parallel()
 
 	responseTickets := testutils.DataFromFile(t, "read/incremental/tickets.json")
+	responseTicketsCustomFields := testutils.DataFromFile(t, "read/custom_fields/ticket_fields.json")
 	responseUsersFirstPage := testutils.DataFromFile(t, "read/incremental/users-1-first-page.json")
 	responseUsersLastPage := testutils.DataFromFile(t, "read/incremental/users-2-last-page.json")
 	responseOrganizations := testutils.DataFromFile(t, "read/incremental/organizations.json")
 
 	tests := []testroutines.Read{
 		{
-			Name: "Incremental Tickets no since",
+			Name: "Incremental Tickets no since with custom fields",
 			Input: common.ReadParams{
 				ObjectName: "tickets",
-				Fields:     connectors.Fields("id"),
+				Fields:     connectors.Fields("id", "Customer Type", "Topic"),
 			},
-			Server: mockserver.Conditional{
+			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
-				If: mockcond.And{
-					mockcond.QueryParam("per_page", "2000"),
-					mockcond.QueryParam("start_time", "0"),
-					mockcond.PathSuffix("/api/v2/incremental/tickets/cursor"),
-				},
-				Then: mockserver.Response(http.StatusOK, responseTickets),
+				Cases: []mockserver.Case{{
+					If: mockcond.And{
+						mockcond.QueryParam("per_page", "2000"),
+						mockcond.QueryParam("start_time", "0"),
+						mockcond.PathSuffix("/api/v2/incremental/tickets/cursor"),
+					},
+					Then: mockserver.Response(http.StatusOK, responseTickets),
+				}, {
+					If:   mockcond.PathSuffix("/api/v2/ticket_fields"),
+					Then: mockserver.Response(http.StatusOK, responseTicketsCustomFields),
+				}},
 			}.Server(),
 			Comparator: testroutines.ComparatorSubsetRead,
 			Expected: &common.ReadResult{
 				Rows: 1,
 				Data: []common.ReadResultRow{{
-					Fields: map[string]any{"id": float64(5)},
-					Raw:    map[string]any{"priority": "normal"},
+					Fields: map[string]any{
+						"id":            float64(5),
+						"customer type": "standard_customer",
+						"topic":         "inquiry",
+					},
+					Raw: map[string]any{
+						"priority": "normal",
+						"custom_fields": []any{
+							map[string]any{
+								"id":    float64(26363655924371),
+								"value": "standard_customer",
+							},
+							map[string]any{
+								"id":    float64(26363685850259),
+								"value": "inquiry",
+							},
+						},
+					},
 				}},
 				NextPage: "",
 				Done:     true,
@@ -177,14 +199,19 @@ func TestIncrementalReadZendeskSupportModule(t *testing.T) { //nolint:funlen,goc
 				Fields:     connectors.Fields("id"),
 				Since:      time.Unix(1726674883, 0),
 			},
-			Server: mockserver.Conditional{
+			Server: mockserver.Switch{
 				Setup: mockserver.ContentJSON(),
-				If: mockcond.And{
-					mockcond.QueryParam("per_page", "2000"),
-					mockcond.QueryParam("start_time", "1726674883"),
-					mockcond.PathSuffix("/api/v2/incremental/tickets/cursor"),
-				},
-				Then: mockserver.Response(http.StatusOK, responseTickets),
+				Cases: []mockserver.Case{{
+					If: mockcond.And{
+						mockcond.QueryParam("per_page", "2000"),
+						mockcond.QueryParam("start_time", "1726674883"),
+						mockcond.PathSuffix("/api/v2/incremental/tickets/cursor"),
+					},
+					Then: mockserver.Response(http.StatusOK, responseTickets),
+				}, {
+					If:   mockcond.PathSuffix("/api/v2/ticket_fields"),
+					Then: mockserver.Response(http.StatusOK, responseTicketsCustomFields),
+				}},
 			}.Server(),
 			Comparator: testroutines.ComparatorPagination,
 			Expected:   &common.ReadResult{Rows: 1, NextPage: "", Done: true},
diff --git a/providers/zendesksupport/test/read/custom_fields/ticket_fields.json b/providers/zendesksupport/test/read/custom_fields/ticket_fields.json
new file mode 100644
index 0000000000..0b3029845b
--- /dev/null
+++ b/providers/zendesksupport/test/read/custom_fields/ticket_fields.json
@@ -0,0 +1,415 @@
+{
+  "ticket_fields": [
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363596559123.json",
+      "id": 26363596559123,
+      "type": "subject",
+      "title": "Subject",
+      "raw_title": "Subject",
+      "description": "",
+      "raw_description": "",
+      "position": 1,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Subject",
+      "raw_title_in_portal": "Subject",
+      "visible_in_portal": true,
+      "editable_in_portal": true,
+      "required_in_portal": true,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": false,
+      "key": null,
+      "agent_description": null
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363643077907.json",
+      "id": 26363643077907,
+      "type": "description",
+      "title": "Description",
+      "raw_title": "Description",
+      "description": "Please enter the details of your request. A member of our support staff will respond as soon as possible.",
+      "raw_description": "Please enter the details of your request. A member of our support staff will respond as soon as possible.",
+      "position": 2,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Description",
+      "raw_title_in_portal": "Description",
+      "visible_in_portal": true,
+      "editable_in_portal": true,
+      "required_in_portal": true,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": false,
+      "key": null,
+      "agent_description": null
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363596563731.json",
+      "id": 26363596563731,
+      "type": "status",
+      "title": "Status",
+      "raw_title": "Status",
+      "description": "Request status",
+      "raw_description": "Request status",
+      "position": 3,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Status",
+      "raw_title_in_portal": "Status",
+      "visible_in_portal": false,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": false,
+      "key": null,
+      "agent_description": null,
+      "system_field_options": [
+        {
+          "name": "Open",
+          "value": "open"
+        },
+        {
+          "name": "Pending",
+          "value": "pending"
+        },
+        {
+          "name": "Solved",
+          "value": "solved"
+        }
+      ],
+      "sub_type_id": 0
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363596567827.json",
+      "id": 26363596567827,
+      "type": "tickettype",
+      "title": "Type",
+      "raw_title": "Type",
+      "description": "Request type",
+      "raw_description": "Request type",
+      "position": 4,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Type",
+      "raw_title_in_portal": "Type",
+      "visible_in_portal": false,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": true,
+      "key": null,
+      "agent_description": null,
+      "system_field_options": [
+        {
+          "name": "Question",
+          "value": "question"
+        },
+        {
+          "name": "Incident",
+          "value": "incident"
+        },
+        {
+          "name": "Problem",
+          "value": "problem"
+        },
+        {
+          "name": "Task",
+          "value": "task"
+        }
+      ]
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363643088147.json",
+      "id": 26363643088147,
+      "type": "priority",
+      "title": "Priority",
+      "raw_title": "Priority",
+      "description": "Request priority",
+      "raw_description": "Request priority",
+      "position": 5,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Priority",
+      "raw_title_in_portal": "Priority",
+      "visible_in_portal": true,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": true,
+      "key": null,
+      "agent_description": null,
+      "system_field_options": [
+        {
+          "name": "Low",
+          "value": "low"
+        },
+        {
+          "name": "Normal",
+          "value": "normal"
+        },
+        {
+          "name": "High",
+          "value": "high"
+        },
+        {
+          "name": "Urgent",
+          "value": "urgent"
+        }
+      ],
+      "sub_type_id": 0
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363596569875.json",
+      "id": 26363596569875,
+      "type": "group",
+      "title": "Group",
+      "raw_title": "Group",
+      "description": "Request group",
+      "raw_description": "Request group",
+      "position": 6,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Group",
+      "raw_title_in_portal": "Group",
+      "visible_in_portal": false,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": false,
+      "key": null,
+      "agent_description": null
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363643096979.json",
+      "id": 26363643096979,
+      "type": "assignee",
+      "title": "Assignee",
+      "raw_title": "Assignee",
+      "description": "Agent assigned to your request",
+      "raw_description": "Agent assigned to your request",
+      "position": 7,
+      "active": true,
+      "required": true,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Assignee",
+      "raw_title_in_portal": "Assignee",
+      "visible_in_portal": true,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": false,
+      "key": null,
+      "agent_description": null
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363596566419.json",
+      "id": 26363596566419,
+      "type": "custom_status",
+      "title": "Ticket status",
+      "raw_title": "Ticket status",
+      "description": "Request ticket status",
+      "raw_description": "Request ticket status",
+      "position": 8,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Ticket status",
+      "raw_title_in_portal": "Ticket status",
+      "visible_in_portal": false,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:23:02Z",
+      "updated_at": "2024-02-15T16:23:02Z",
+      "removable": false,
+      "key": null,
+      "agent_description": null,
+      "custom_statuses": [
+        {
+          "url": "https://d3v-ampersand.zendesk.com/api/v2/custom_statuses/26363596805011.json",
+          "id": 26363596805011,
+          "status_category": "new",
+          "agent_label": "New",
+          "end_user_label": "Open",
+          "description": "Ticket is awaiting assignment to an agent",
+          "end_user_description": "We are working on a response for you",
+          "active": true,
+          "default": true,
+          "created_at": "2024-02-15T16:23:04Z",
+          "updated_at": "2024-02-15T16:23:04Z"
+        },
+        {
+          "url": "https://d3v-ampersand.zendesk.com/api/v2/custom_statuses/26363643424147.json",
+          "id": 26363643424147,
+          "status_category": "open",
+          "agent_label": "Open",
+          "end_user_label": "Open",
+          "description": "Staff is working on the ticket",
+          "end_user_description": "We are working on a response for you",
+          "active": true,
+          "default": true,
+          "created_at": "2024-02-15T16:23:04Z",
+          "updated_at": "2024-02-15T16:23:04Z"
+        },
+        {
+          "url": "https://d3v-ampersand.zendesk.com/api/v2/custom_statuses/26363643430931.json",
+          "id": 26363643430931,
+          "status_category": "pending",
+          "agent_label": "Pending",
+          "end_user_label": "Awaiting your reply",
+          "description": "Staff is waiting for the requester to reply",
+          "end_user_description": "We are waiting for you to respond",
+          "active": true,
+          "default": true,
+          "created_at": "2024-02-15T16:23:04Z",
+          "updated_at": "2024-02-15T16:23:04Z"
+        },
+        {
+          "url": "https://d3v-ampersand.zendesk.com/api/v2/custom_statuses/26363643436691.json",
+          "id": 26363643436691,
+          "status_category": "hold",
+          "agent_label": "On-hold",
+          "end_user_label": "Open",
+          "description": "Staff is waiting for a third party",
+          "end_user_description": "We are working on a response for you",
+          "active": false,
+          "default": true,
+          "created_at": "2024-02-15T16:23:04Z",
+          "updated_at": "2024-02-15T16:23:04Z"
+        },
+        {
+          "url": "https://d3v-ampersand.zendesk.com/api/v2/custom_statuses/26363643446547.json",
+          "id": 26363643446547,
+          "status_category": "solved",
+          "agent_label": "Solved",
+          "end_user_label": "Solved",
+          "description": "The ticket has been solved",
+          "end_user_description": "This request has been solved",
+          "active": true,
+          "default": true,
+          "created_at": "2024-02-15T16:23:04Z",
+          "updated_at": "2024-02-15T16:23:04Z"
+        }
+      ]
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363655924371.json",
+      "id": 26363655924371,
+      "type": "tagger",
+      "title": "Customer Type",
+      "raw_title": "Customer Type",
+      "description": "Customer Type",
+      "raw_description": "Customer Type",
+      "position": 9999,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Customer Type",
+      "raw_title_in_portal": "Customer Type",
+      "visible_in_portal": false,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:24:00Z",
+      "updated_at": "2024-02-15T16:24:00Z",
+      "removable": true,
+      "key": null,
+      "agent_description": null,
+      "custom_field_options": [
+        {
+          "id": 26363655921683,
+          "name": "VIP Customer",
+          "raw_name": "VIP Customer",
+          "value": "vip_customer",
+          "default": false
+        },
+        {
+          "id": 26363655921811,
+          "name": "Standard Customer",
+          "raw_name": "Standard Customer",
+          "value": "standard_customer",
+          "default": false
+        }
+      ]
+    },
+    {
+      "url": "https://d3v-ampersand.zendesk.com/api/v2/ticket_fields/26363685850259.json",
+      "id": 26363685850259,
+      "type": "tagger",
+      "title": "Topic",
+      "raw_title": "Topic",
+      "description": "Topic",
+      "raw_description": "Topic",
+      "position": 9999,
+      "active": true,
+      "required": false,
+      "collapsed_for_agents": false,
+      "regexp_for_validation": null,
+      "title_in_portal": "Topic",
+      "raw_title_in_portal": "Topic",
+      "visible_in_portal": false,
+      "editable_in_portal": false,
+      "required_in_portal": false,
+      "tag": null,
+      "created_at": "2024-02-15T16:24:00Z",
+      "updated_at": "2024-02-15T16:24:00Z",
+      "removable": true,
+      "key": null,
+      "agent_description": null,
+      "custom_field_options": [
+        {
+          "id": 26363685849363,
+          "name": "Issue",
+          "raw_name": "Issue",
+          "value": "issue",
+          "default": false
+        },
+        {
+          "id": 26363685849491,
+          "name": "Inquiry",
+          "raw_name": "Inquiry",
+          "value": "inquiry",
+          "default": false
+        },
+        {
+          "id": 26363685849619,
+          "name": "Other",
+          "raw_name": "Other",
+          "value": "other",
+          "default": false
+        }
+      ]
+    }
+  ],
+  "next_page": null,
+  "previous_page": null,
+  "count": 10
+}
diff --git a/test/zendesksupport/metadata/tickets/main.go b/test/zendesksupport/metadata/tickets/main.go
index 6adaf3b5e1..95dccfca43 100644
--- a/test/zendesksupport/metadata/tickets/main.go
+++ b/test/zendesksupport/metadata/tickets/main.go
@@ -11,8 +11,6 @@ import (
 	connTest "github.com/amp-labs/connectors/test/zendesksupport"
 )
 
-var objectName = "tickets" // nolint: gochecknoglobals
-
 // We want to compare fields returned by read and schema properties provided by metadata methods.
 // Properties from read must all be present in schema definition.
 func main() {
@@ -26,7 +24,7 @@ func main() {
 	conn := connTest.GetZendeskSupportConnector(ctx)
 
 	metadata, err := conn.ListObjectMetadata(ctx, []string{
-		objectName,
+		"tickets", "requests",
 	})
 	if err != nil {
 		utils.Fail("error listing metadata for ZendeskSupport", "error", err)
diff --git a/test/zendesksupport/read/tickets/main.go b/test/zendesksupport/read/tickets/main.go
index 4e8be8a7f9..130dac8391 100644
--- a/test/zendesksupport/read/tickets/main.go
+++ b/test/zendesksupport/read/tickets/main.go
@@ -30,7 +30,7 @@ func main() {
 
 	res, err := conn.Read(ctx, common.ReadParams{
 		ObjectName: objectName,
-		Fields:     connectors.Fields("description"),
+		Fields:     connectors.Fields("description", "Customer Type", "Topic"),
 		// Since:      time.Now().Add(-1 * time.Hour * 24 * 180),
 		// Pagination returned when since was not specified:
 		// NextPage:   "https://d3v-ampersand.zendesk.com/api/v2/incremental/tickets/cursor.json?cursor=MTcwODAxNDI2MS4wfHw1fA%3D%3D&per_page=1", // nolint:lll


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR adds support for human-readable custom fields in the Zendesk Support connector, focusing on Tickets and Requests objects.

- Implements `requestCustomFields` method in `metadata.go` to fetch and attach custom field metadata to object definitions
- Creates field type mapping logic in `ticketField.GetValueType()` to properly convert Zendesk field types to Ampersand's ValueType system
- Adds support for retrieving field options (single/multi-select values) through the `getValues()` method
- Moves `ErrResolvingCustomFields` from Keap package to common package for reuse across connectors
- Adds comprehensive test coverage with mock responses to validate custom field handling for both metadata and read operations

Greptile AI

<sub>11 file(s) reviewed, 3 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=connectors_1411)</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-10T17:52:28Z
user: login: RajatPawar
body: @Cobalt0s does this also alter the raw response? can you please add examples of how it looks currently and how it would look after the change?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T21:41:17Z
user: login: Cobalt0s
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:08:22Z
user: login: Cobalt0s
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:21:01Z
user: login: Cobalt0s
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:55:31Z
user: login: RajatPawar
body: LGTM. Nice work!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-17T21:22:45Z

Review comments:
diff_hunk: @@ -10,5 +10,31 @@ import (
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
-	return metadata.Schemas.Select(c.Module.ID, objectNames)
+	metadataResult, err := metadata.Schemas.Select(c.Module.ID, objectNames)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, objectName := range objectNames {
+		fields, err := c.requestCustomFields(ctx, objectName)
+		if err != nil {
+			metadataResult.Errors[objectName] = err
+
+			continue
+		}
+
+		// Attach fields to the object metadata.
+		objectMetadata := metadataResult.Result[objectName]
path: providers/zendesksupport/metadata.go
user: login: greptile-apps[bot]
body: logic: Accessing map value without checking if the key exists first. If objectName isn't in the Result map, this could cause a nil pointer dereference.

```suggestion
		objectMetadata, ok := metadataResult.Result[objectName]
		if !ok {
			continue
		}
```
created_at: 2025-04-10T17:50:56Z
author_association: NONE
diff_hunk: @@ -333,6 +336,12 @@ type ObjectMetadata struct {
 	FieldsMap map[string]string
 }
 
+// AddFieldMetadata updates Fields and FieldsMap fields ensuring data consistency.
+func (m ObjectMetadata) AddFieldMetadata(fieldName string, fieldMetadata FieldMetadata) {
+	m.Fields[fieldName] = fieldMetadata
+	m.FieldsMap[fieldName] = fieldMetadata.DisplayName
+}
path: common/types.go
user: login: greptile-apps[bot]
body: logic: This method should use a pointer receiver to ensure the changes are persisted. Currently using a value receiver which means changes won't affect the original struct.

```suggestion
// AddFieldMetadata updates Fields and FieldsMap fields ensuring data consistency.
func (m *ObjectMetadata) AddFieldMetadata(fieldName string, fieldMetadata FieldMetadata) {
	m.Fields[fieldName] = fieldMetadata
	m.FieldsMap[fieldName] = fieldMetadata.DisplayName
}
```
created_at: 2025-04-10T17:51:00Z
author_association: NONE
diff_hunk: @@ -93,6 +97,72 @@ func TestListObjectMetadataZendeskSupportModule(t *testing.T) { // nolint:funlen
 			},
 			ExpectedErrs: nil,
 		},
+		{
+			Name:  "Successfully describe ticket custom fields",
+			Input: []string{"tickets"},
+			Server: mockserver.Conditional{
+				Setup: mockserver.ContentJSON(),
+				If:    mockcond.PathSuffix("/api/v2/ticket_fields"),
+				Then:  mockserver.Response(http.StatusOK, responseTicketsCustomFields),
+			}.Server(),
+			Comparator: testroutines.ComparatorSubsetMetadata,
+			Expected: &common.ListObjectMetadataResult{
+				Result: map[string]common.ObjectMetadata{
+					"tickets": {
+						DisplayName: "Tickets",
+						Fields: map[string]common.FieldMetadata{
+							"comment": {
+								DisplayName:  "comment",
+								ValueType:    "other",
+								ProviderType: "object",
+								ReadOnly:     false,
+							},
+							"priority": {
+								DisplayName:  "priority",
+								ValueType:    "singleSelect",
+								ProviderType: "string",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "urgent",
+									DisplayValue: "urgent",
+								}, {
+									Value:        "high",
+									DisplayValue: "high",
+								}, {
+									Value:        "normal",
+									DisplayValue: "normal",
+								}, {
+									Value:        "low",
+									DisplayValue: "low",
+								}},
+							},
+							// Custom field
+							"Customer Type": {
+								DisplayName:  "Customer Type",
+								ValueType:    "singleSelect",
+								ProviderType: "tagger",
+								ReadOnly:     false,
+								Values: []common.FieldValue{{
+									Value:        "vip_customer",
+									DisplayValue: "VIP Customer",
+								}, {
+									Value:        "standard_customer",
+									DisplayValue: "Standard Customer",
+								}},
+							},
+						},
+						FieldsMap: map[string]string{
+							"comment": "comment",
+							// Custom fields
+							"Customer Type": "Customer Type",
+							"Topic":         "Topic",
+						},
path: providers/zendesksupport/metadata_test.go
user: login: greptile-apps[bot]
body: style: The test verifies 'Customer Type' is properly mapped, but 'Topic' appears in FieldsMap without corresponding entry in Fields map. Consider adding the complete metadata for 'Topic' in the Fields map for consistency.
created_at: 2025-04-10T17:51:34Z
author_association: NONE
diff_hunk: @@ -333,6 +336,12 @@ type ObjectMetadata struct {
 	FieldsMap map[string]string
 }
 
+// AddFieldMetadata updates Fields and FieldsMap fields ensuring data consistency.
+func (m ObjectMetadata) AddFieldMetadata(fieldName string, fieldMetadata FieldMetadata) {
+	m.Fields[fieldName] = fieldMetadata
+	m.FieldsMap[fieldName] = fieldMetadata.DisplayName
+}
path: common/types.go
user: login: RajatPawar
body: We should make this change
created_at: 2025-04-15T20:24:58Z
author_association: COLLABORATOR
diff_hunk: @@ -10,5 +10,31 @@ import (
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
-	return metadata.Schemas.Select(c.Module.ID, objectNames)
+	metadataResult, err := metadata.Schemas.Select(c.Module.ID, objectNames)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, objectName := range objectNames {
+		fields, err := c.requestCustomFields(ctx, objectName)
path: providers/zendesksupport/metadata.go
user: login: RajatPawar
body: nit: might be good to spawn a goroutine per object for this (but I'd make sure that the API supports concurrency & has limits supporting this)
created_at: 2025-04-15T21:34:39Z
author_association: COLLABORATOR
diff_hunk: @@ -10,5 +10,31 @@ import (
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
-	return metadata.Schemas.Select(c.Module.ID, objectNames)
+	metadataResult, err := metadata.Schemas.Select(c.Module.ID, objectNames)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, objectName := range objectNames {
+		fields, err := c.requestCustomFields(ctx, objectName)
+		if err != nil {
+			metadataResult.Errors[objectName] = err
+
+			continue
+		}
+
+		// Attach fields to the object metadata.
+		objectMetadata := metadataResult.Result[objectName]
+		for _, field := range fields {
+			objectMetadata.AddFieldMetadata(field.Title, common.FieldMetadata{
path: providers/zendesksupport/metadata.go
user: login: RajatPawar
body: can we refactor this function to return a new metadata object after mutating? Easy to run into issues with functions that mutate
created_at: 2025-04-15T21:36:22Z
author_association: COLLABORATOR
diff_hunk: @@ -49,3 +51,11 @@ func getNextRecordsURL(node *ajson.Node) (string, error) {
 
 	return jsonquery.New(node).StrWithDefault("after_url", "")
 }
+
+func makeGetRecords(moduleID common.ModuleID, objectName string) common.NodeRecordsFunc {
path: providers/zendesksupport/parse.go
user: login: RajatPawar
body: I understand the reasoning behind `make` as a prefix, but can we please just call this `getRecords` to make it easier to read?
created_at: 2025-04-15T21:37:15Z
author_association: COLLABORATOR
diff_hunk: @@ -89,3 +95,190 @@ func formatStartTime(config common.ReadParams) string {
 
 	return strconv.FormatInt(unixTime, 10)
 }
+
+// requestCustomFields makes and API call to get model describing custom fields.
+// For not applicable objects the empty mapping is returned.
+// The mapping is between "custom field id" and struct containing "human-readable field name".
+//
+// Custom fields are always associated with "ticket_fields" regardless of the object type.
path: providers/zendesksupport/read.go
user: login: RajatPawar
body: might make it easier to read if we separate this out into a `customFields.go` file
created_at: 2025-04-15T21:38:21Z
author_association: COLLABORATOR
diff_hunk: @@ -10,5 +10,31 @@ import (
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
-	return metadata.Schemas.Select(c.Module.ID, objectNames)
+	metadataResult, err := metadata.Schemas.Select(c.Module.ID, objectNames)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, objectName := range objectNames {
+		fields, err := c.requestCustomFields(ctx, objectName)
path: providers/zendesksupport/metadata.go
user: login: Cobalt0s
body: Good idea! Done!
created_at: 2025-04-16T17:08:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,5 +10,31 @@ import (
 func (c *Connector) ListObjectMetadata(
 	ctx context.Context, objectNames []string,
 ) (*common.ListObjectMetadataResult, error) {
-	return metadata.Schemas.Select(c.Module.ID, objectNames)
+	metadataResult, err := metadata.Schemas.Select(c.Module.ID, objectNames)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, objectName := range objectNames {
+		fields, err := c.requestCustomFields(ctx, objectName)
+		if err != nil {
+			metadataResult.Errors[objectName] = err
+
+			continue
+		}
+
+		// Attach fields to the object metadata.
+		objectMetadata := metadataResult.Result[objectName]
+		for _, field := range fields {
+			objectMetadata.AddFieldMetadata(field.Title, common.FieldMetadata{
path: providers/zendesksupport/metadata.go
user: login: Cobalt0s
body: Initial object is not empty, it is a deep copy of `metadata.Schemas`.
We could copy it again but each go routine will be responsible for one part of map, one object-metadata. For distributed processing this is an appropriate scenario, because map is logically partitioned and there is no overlap for side effects to collide.
created_at: 2025-04-16T17:21:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -333,6 +336,12 @@ type ObjectMetadata struct {
 	FieldsMap map[string]string
 }
 
+// AddFieldMetadata updates Fields and FieldsMap fields ensuring data consistency.
+func (m ObjectMetadata) AddFieldMetadata(fieldName string, fieldMetadata FieldMetadata) {
+	m.Fields[fieldName] = fieldMetadata
+	m.FieldsMap[fieldName] = fieldMetadata.DisplayName
+}
path: common/types.go
user: login: Cobalt0s
body: `greptile-apps` is incorrect in many cases. The result behaves as expected. Since the value is passed by value, the address field is copied into the Fields map. We can have multiple `ObjectMetadata` instances that are copies of each other, but since slices and maps reference shared underlying data, changes will still be reflected across instances.

The suggestion from `greptile-apps` only applies to immutable variables that reside directly in `ObjectMetadata`, which we do not modify.

Additionally, there's a convention that all struct receivers should be consistent — if one method uses a pointer receiver, then all should, to indicate that the struct is `modifiable`. This isn't perfect, though, since structs can still be effectively modifiable due to `internal references` like maps or slices.

To avoid confusion, I may switch to using a pointer receiver, though the change isn't functionally necessary.
created_at: 2025-04-16T17:55:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: Cobalt0s
created_at: 2025-02-27T01:30:35Z
body: * **#1411** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1411?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1411?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#1410** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1410?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1321** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1321?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 5 other dependent PRs ([#1320](https://github.com/amp-labs/connectors/pull/1320) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1320?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>, [#1324](https://github.com/amp-labs/connectors/pull/1324) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1324?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>, [#1325](https://github.com/amp-labs/connectors/pull/1325) <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/1325?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> and 2 others)
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: RajatPawar
created_at: 2025-04-10T17:50:23Z
body: @greptileai
author_association: COLLABORATOR
user: login: Cobalt0s
created_at: 2025-04-16T18:01:23Z
body: @RajatPawar 
You know, I noticed that since only 2 objects support custom fields and both of them would make the same API call I think we do not need the go routines, any parallel processing and better to make only one API call.

Objects: tickets, requests --> request custom fields can share an API call instead of:
![image](https://github.com/user-attachments/assets/22d83561-6448-44da-9585-b6df16df092e)

PS: the PR description is changed to demonstrate that `fields =/= raw`,  see `Test` section.

author_association: CONTRIBUTOR