We are trying to create some triggers by utilizing JIRA events in our Forge app. Previously, we have supported only SCRUM-type projects, and now we’re trying to enable support for all types of projects. In this case, we need to map all the JIRA fields—including custom fields—for specific projects and issue types. We’re attempting to get all possible values for both custom and default fields. Since the fields differ according to project and issue types, we haven’t been able to create a common handler for this. We’ve tried the following approaches:
Approach 1:
**Get create field metadata for a project and issue type id :**
We tried using this endpoint, but it only provides allowed values for default fields and some custom fields with type “option.”
This approach fails for most custom fields with types such as array, select, team, user, group, organizations, request participants, linked issues, etc.
We even tried to conditionally use user search and issue type search, but for other types, we don’t have specific endpoints available.
Therefore, we can’t create a common handler capable of getting all fields with possible values for all project and issue types.
Approach 2:
**Get issue types for custom field context
Get custom field contexts**
We tried using issue field context endpoints, but we’re getting the same data as the create field meta, just in a different structure. Even with this approach, we weren’t able to achieve our goal.
FYI, I will be attaching the code for these approaches here. Can you help us get all the issue fields with possible options?
Approach 1 :
resolver.define(RESOLVERS.GET_CUSTOM_FIELD_VALUES, async (req) => {
try {
const { fieldName, fieldId, projectKey, issueTypeId } = req.payload;
console.log(`[DEBUG] Getting custom field values for: ${fieldName || fieldId}`);
let targetField = null;
let targetFieldId = fieldId;
// Step 1: Get field ID if only field name is provided
if (fieldName && !fieldId) {
console.log(`[DEBUG] Searching for field by name: ${fieldName}`);
const fieldsResponse = await api.asUser().requestJira(route`/rest/api/3/field`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!fieldsResponse.ok) {
throw new Error(`Failed to fetch fields: ${fieldsResponse.statusText}`);
}
const fields = await fieldsResponse.json();
targetField = fields.find(f => f.name === fieldName);
if (!targetField) {
throw new Error(`Field "${fieldName}" not found`);
}
targetFieldId = targetField.id;
console.log(`[DEBUG] Found field: ${targetFieldId} (${targetField.schema?.type})`);
} else if (fieldId) {
// Get field details by ID
console.log(`[DEBUG] Getting field details for ID: ${fieldId}`);
const fieldsResponse = await api.asUser().requestJira(route`/rest/api/3/field`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (fieldsResponse.ok) {
const fields = await fieldsResponse.json();
targetField = fields.find(f => f.id === fieldId);
}
}
if (!targetField) {
throw new Error(`Field not found: ${fieldName || fieldId}`);
}
// Step 2: Handle different field types
const schemaType = targetField.schema?.type;
const customType = targetField.schema?.custom || '';
const isCustomField = targetFieldId.startsWith('customfield_');
console.log(`[DEBUG] Field details:`, {
id: targetFieldId,
name: targetField.name,
type: schemaType,
custom: customType,
isCustomField
});
let values = [];
// Handle option-based fields (select lists, checkboxes, radio buttons)
if ((schemaType === 'option') ||
(schemaType === 'array' && targetField.schema?.items === 'option') ||
(customType && (customType.includes('select') ||
customType.includes('radiobuttons') ||
customType.includes('checkboxes')))) {
console.log(`[DEBUG] Processing option-based field`);
values = await getFixedOptions(targetFieldId);
}
// Handle user picker fields
else if ((schemaType === 'user') ||
(schemaType === 'array' && targetField.schema?.items === 'user') ||
(customType && customType.includes('userpicker'))) {
console.log(`[DEBUG] Processing user picker field`);
values = await getUsers(projectKey);
}
// Handle cascading select fields
else if (customType && customType.includes('cascadingselect')) {
console.log(`[DEBUG] Processing cascading select field`);
values = await getCascadingSelectOptions(targetFieldId);
}
// Handle group picker fields
else if (customType && customType.includes('grouppicker')) {
console.log(`[DEBUG] Processing group picker field`);
values = await getGroups();
}
// Handle version fields
else if (customType && customType.includes('version')) {
console.log(`[DEBUG] Processing version field`);
if (!projectKey) {
throw new Error('Project key is required for version fields');
}
values = await getVersions(projectKey);
}
// Handle component fields
else if (customType && customType.includes('component')) {
console.log(`[DEBUG] Processing component field`);
if (!projectKey) {
throw new Error('Project key is required for component fields');
}
values = await getComponents(projectKey);
}
// Fallback: try issue metadata approach
else {
console.log(`[DEBUG] Using issue metadata approach`);
if (!projectKey || !issueTypeId) {
console.warn(`[DEBUG] Project key and issue type ID needed for metadata approach`);
values = [];
} else {
values = await getViaIssueMetadata(targetFieldId, projectKey, issueTypeId);
}
}
console.log(`[DEBUG] Found ${values.length} values for field "${targetField.name}"`);
return {
success: true,
data: {
field: {
id: targetFieldId,
name: targetField.name,
type: schemaType,
customType: customType,
isCustomField
},
allowedValues: targetField.allowedValues,
values: values,
totalCount: values.length
}
};
} catch (error) {
console.error(`[DEBUG] Error getting custom field values:`, error);
throw error;
}
});
// Helper function to get fixed options (select lists, checkboxes, etc.)
async function getFixedOptions(fieldId) {
try {
console.log(`[DEBUG] Getting fixed options for field: ${fieldId}`);
let allOptions = [];
let startAt = 0;
const maxResults = 100;
// Get contexts (paginated)
let contexts = [];
do {
const ctxResponse = await api.asUser().requestJira(
route`/rest/api/3/field/${fieldId}/context?startAt=${startAt}&maxResults=${maxResults}`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!ctxResponse.ok) {
console.warn(`[DEBUG] Failed to get contexts: ${ctxResponse.status}`);
break;
}
const ctxData = await ctxResponse.json();
contexts = contexts.concat(ctxData.values || []);
startAt += (ctxData.values || []).length;
if (!ctxData.values || ctxData.values.length < maxResults) break;
} while (true);
console.log(`[DEBUG] Found ${contexts.length} contexts`);
// Get options from each context
for (const context of contexts) {
let optionStartAt = 0;
do {
const optResponse = await api.asUser().requestJira(
route`/rest/api/3/field/${fieldId}/context/${context.id}/option?startAt=${optionStartAt}&maxResults=${maxResults}`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!optResponse.ok) {
console.warn(`[DEBUG] Failed to get options for context ${context.id}: ${optResponse.status}`);
break;
}
const optData = await optResponse.json();
const contextOptions = (optData.values || []).map(v => ({
id: v.id,
value: v.value,
disabled: v.disabled || false,
optionId: v.optionId
}));
allOptions = allOptions.concat(contextOptions);
optionStartAt += (optData.values || []).length;
if (!optData.values || optData.values.length < maxResults) break;
} while (true);
}
// Remove duplicates based on value
const uniqueOptions = allOptions.filter((option, index, self) =>
index === self.findIndex(o => o.value === option.value)
);
console.log(`[DEBUG] Found ${uniqueOptions.length} unique options`);
return uniqueOptions;
} catch (error) {
console.error(`[DEBUG] Error getting fixed options:`, error);
return [];
}
}
// Helper function to get users
async function getUsers(projectKey = null) {
try {
console.log(`[DEBUG] Getting users${projectKey ? ` for project ${projectKey}` : ''}`);
// Step 1: Get users using general user search
const usersResponse = await api.asUser().requestJira(
route`/rest/api/3/users/search?maxResults=1000`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!usersResponse.ok) {
console.warn(`[DEBUG] Failed to get users: ${usersResponse.status}`);
return [];
}
const users = await usersResponse.json();
console.log(`[DEBUG] Found ${users.length} users from general search`);
// Step 2: Get email addresses for users using user email lookup
const usersWithEmails = [];
// Process users in batches to avoid overwhelming the API
const batchSize = 10;
for (let i = 0; i < users.length; i += batchSize) {
const batch = users.slice(i, i + batchSize);
const emailPromises = batch.map(async (user) => {
try {
const emailResponse = await api.asUser().requestJira(
route`/rest/api/3/user/email?accountId=${user.accountId}`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (emailResponse.ok) {
const emailData = await emailResponse.json();
return {
id: user.accountId,
value: user.displayName,
emailAddress: emailData.email || '',
avatarUrl: user.avatarUrls ? user.avatarUrls['48x48'] : null,
accountType: user.accountType,
active: user.active
};
} else {
console.warn(`[DEBUG] Failed to get email for user ${user.accountId}: ${emailResponse.status}`);
return {
id: user.accountId,
value: user.displayName,
emailAddress: '',
avatarUrl: user.avatarUrls ? user.avatarUrls['48x48'] : null,
accountType: user.accountType,
active: user.active
};
}
} catch (emailError) {
console.warn(`[DEBUG] Error getting email for user ${user.accountId}:`, emailError);
return {
id: user.accountId,
value: user.displayName,
emailAddress: '',
avatarUrl: user.avatarUrls ? user.avatarUrls['48x48'] : null,
accountType: user.accountType,
active: user.active
};
}
});
const batchResults = await Promise.all(emailPromises);
usersWithEmails.push(...batchResults);
console.log(`[DEBUG] Processed batch ${Math.floor(i/batchSize) + 1}/${Math.ceil(users.length/batchSize)}`);
}
// Filter for active atlassian users only (optional - you can remove this filter if needed)
const filteredUsers = usersWithEmails.filter(user =>
user.accountType === 'atlassian' && user.active
);
console.log(`[DEBUG] Final user count: ${filteredUsers.length} (filtered from ${usersWithEmails.length})`);
return filteredUsers;
} catch (error) {
console.error(`[DEBUG] Error getting users:`, error);
return [];
}
}
// Helper function to get cascading select options
async function getCascadingSelectOptions(fieldId) {
try {
console.log(`[DEBUG] Getting cascading select options for: ${fieldId}`);
// For cascading selects, we need to get the parent and child options
const options = await getFixedOptions(fieldId);
// Group by parent-child relationships if available
return options;
} catch (error) {
console.error(`[DEBUG] Error getting cascading select options:`, error);
return [];
}
}
// Helper function to get groups
async function getGroups() {
try {
console.log(`[DEBUG] Getting groups`);
const groupsResponse = await api.asUser().requestJira(route`/rest/api/3/groups/picker?maxResults=1000`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!groupsResponse.ok) {
console.warn(`[DEBUG] Failed to get groups: ${groupsResponse.status}`);
return [];
}
const groupsData = await groupsResponse.json();
return (groupsData.groups || []).map(g => ({
id: g.name,
value: g.name,
html: g.html
}));
} catch (error) {
console.error(`[DEBUG] Error getting groups:`, error);
return [];
}
}
// Helper function to get versions
async function getVersions(projectKey) {
try {
console.log(`[DEBUG] Getting versions for project: ${projectKey}`);
const versionsResponse = await api.asUser().requestJira(
route`/rest/api/3/project/${projectKey}/versions`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!versionsResponse.ok) {
console.warn(`[DEBUG] Failed to get versions: ${versionsResponse.status}`);
return [];
}
const versions = await versionsResponse.json();
return versions.map(v => ({
id: v.id,
value: v.name,
description: v.description,
released: v.released,
archived: v.archived
}));
} catch (error) {
console.error(`[DEBUG] Error getting versions:`, error);
return [];
}
}
// Helper function to get components
async function getComponents(projectKey) {
try {
console.log(`[DEBUG] Getting components for project: ${projectKey}`);
const componentsResponse = await api.asUser().requestJira(
route`/rest/api/3/project/${projectKey}/components`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!componentsResponse.ok) {
console.warn(`[DEBUG] Failed to get components: ${componentsResponse.status}`);
return [];
}
const components = await componentsResponse.json();
return components.map(c => ({
id: c.id,
value: c.name,
description: c.description,
leadAccountId: c.leadAccountId
}));
} catch (error) {
console.error(`[DEBUG] Error getting components:`, error);
return [];
}
}
// Helper function to get values via issue metadata
async function getViaIssueMetadata(fieldId, projectKey, issueTypeId) {
try {
console.log(`[DEBUG] Getting values via issue metadata for field: ${fieldId}`);
const metaResponse = await api.asUser().requestJira(
route`/rest/api/3/issue/createmeta/${projectKey}/issuetypes/${issueTypeId}`, {
headers: { [STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON }
});
if (!metaResponse.ok) {
console.warn(`[DEBUG] Failed to get issue metadata: ${metaResponse.status}`);
return [];
}
const metaData = await metaResponse.json();
const fieldData = metaData.fields?.[fieldId];
if (!fieldData || !fieldData.allowedValues) {
console.log(`[DEBUG] No allowed values found in metadata for field: ${fieldId}`);
return [];
}
return fieldData.allowedValues.map(v => ({
id: v.id || v.value,
value: v.value || v.name,
description: v.description || '',
iconUrl: v.iconUrl
}));
} catch (error) {
console.error(`[DEBUG] Error getting values via issue metadata:`, error);
return [];
}
}
Approach 2 :
resolver.define(RESOLVERS.GET_ALL_JIRA_FIELDS_WITH_VALUES, async (req) => {
const getContextId = async (fieldId) => {
const response = await api.asUser().requestJira(
route`/rest/api/3/field/${fieldId}/context/issuetypemapping`, {
headers: {
[STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON,
},
});
const meta = await api.asUser().requestJira(
route`/rest/api/3/field/${fieldId}/context`, {
headers: {
[STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON,
},
});
const metaData = await meta.json();
const context = response.json();
const data = {
...context,
meta: metaData
}
return data;
}
const getCustomFieldOptions = async (fieldId, contextId, items) => {
const response = await api.asUser().requestJira(
route`/rest/api/3/field/${fieldId}/context/${contextId}/${items}`, {
headers: {
[STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON,
},
});
return response.json();
}
const getVisibleFields = async (fieldId, projectKey) => {
const response = await api.asUser().requestJira(
route`/rest/api/3/field/${fieldId}/option/suggestions/search?startAt=0&maxResults=50&projectId=${projectKey}`, {
headers: {
[STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON,
},
});
return response.json();
}
try {
const { projectKey, issueTypeId } = req.payload;
console.log(`[DEBUG] Starting getAllJiraFieldsWithValues for project: ${projectKey}, issueType: ${issueTypeId}`);
if (!projectKey || !issueTypeId) {
console.error("[DEBUG] Missing required parameters:", { projectKey, issueTypeId });
throw new Error("Project key and issue type ID are required");
}
// Get field metadata for the specific project and issue type
// console.log(`[DEBUG] Fetching field metadata from: /rest/api/3/issue/createmeta/${projectKey}/issuetypes/${issueTypeId}`);
const fieldMetaResponse = await api.asUser().requestJira(
route`/rest/api/3/issue/createmeta/${projectKey}/issuetypes/${issueTypeId}?expand=projects.issuetypes.fields`, {
headers: {
[STRINGS.API.ACCEPT]: STRINGS.API.CONTENT_TYPE_JSON,
},
});
if (!fieldMetaResponse.ok) {
// console.error(`[DEBUG] Failed to fetch field metadata: ${fieldMetaResponse.status} ${fieldMetaResponse.statusText}`);
throw new Error(`${ERROR_MESSAGES.FAILED_FETCH_FIELDS} ${fieldMetaResponse.statusText}`);
} else {
const fieldMeta = await fieldMetaResponse.json();
// console.log(JSON.stringify(fieldMeta, null, 2), 'fieldMeta')
const customFields = fieldMeta.fields.filter(field => !!field.schema?.customId);
const customFieldWithContextId = await Promise.all(customFields.map(async (field) => {
const context = await getContextId(field.fieldId);
return {
...field,
context: context
};
}));
const customFieldWithOptions = await Promise.all(customFieldWithContextId.map(async (field) => {
const options = await getVisibleFields(field.fieldId, projectKey);
return {
...field,
options: options.values
};
}));
console.log(JSON.stringify(customFieldWithOptions, null, 2), 'customFieldWithOptions')
return customFieldWithOptions;
}
} catch (error) {
console.error(`[DEBUG] Error in getAllJiraFieldsWithValues:`, error);
console.error(ERROR_MESSAGES.JIRA_FIELDS_FETCH, error);
throw error;
}
});