Skip to content
This repository has been archived by the owner on Aug 8, 2024. It is now read-only.

added complex custom field property #224

Merged
merged 6 commits into from
Jun 27, 2024
Merged
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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ LOCAL_SERVER_JIRA_HOST=http://localhost:8080
LOCAL_SERVER_JIRA_USERNAME=jupiterone-dev
LOCAL_SERVER_JIRA_PASSWORD=testing123
LOCAL_SERVER_PROJECTS=["SP"]
CUSTOM_FIELDS=["customField1", "custonField2"]

# Simple Custom Fields
CUSTOM_FIELDS=["customField1", "customField2"]

# Complex Custom Fields
COMPLEX_CUSTOM_FIELDS=["complexField1.path.to.value", "complexField2.path.to.value"]
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export const instanceConfigFields: IntegrationInstanceConfigFieldMap = {
mask: false,
optional: true,
},
complexCustomFields: {
type: 'json',
mask: false,
optional: true,
},
};

/**
Expand Down Expand Up @@ -116,6 +121,13 @@ export interface JiraIntegrationInstanceConfig
*/
customFields?: string[];

/**
* An optional array of complex custom field paths to transfer to `Issue` entity properties.
* The entity property names will be `camelCase(field.path)`.
*
* - `"complexField1.path.to.value"` - a fully qualified identifier
*/
complexCustomFields?: string[];
/**
* Enable bulk ingestion of all Jira issues in the specified projects.
*/
Expand All @@ -130,6 +142,7 @@ export type IntegrationConfig = JiraIntegrationInstanceConfig &
JiraClientConfig & {
projects: string[];
customFields: string[];
complexCustomFields: string[];
};

/**
Expand Down Expand Up @@ -212,6 +225,9 @@ export function buildNormalizedInstanceConfig(
apiVersion: jiraApiVersion,
projects: normalizeProjectKeys(config.projects),
customFields: normalizeCustomFieldIdentifiers(config.customFields),
complexCustomFields: normalizeCustomFieldIdentifiers(
config.complexCustomFields,
),
};
}

Expand Down
86 changes: 66 additions & 20 deletions src/converters/IssueEntityConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,38 @@ function getIssueDescription(issue: Issue, apiVersion: string): string {
: parseContent((description as TextContent).content);
}

function getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((acc, part, index) => {
if (!acc) {
return undefined;
}

const match = part.match(/^(\w+|\[\d+\])(?:\.(.+))?$/);
if (match) {
const [, key] = match;
if (key.startsWith('[') && key.endsWith(']')) {
// Accessing array element
const arrayIndex = Number(key.slice(1, -1));
return acc[arrayIndex];
} else {
// Accessing object property
return acc[key];
}
}
return acc[part];
}, obj);
}

function setFlatNestedValue(obj: any, path: string, value: any): void {
obj[path] = value;
}

export function createIssueEntity({
issue,
logger,
fieldsById,
customFieldsToInclude,
complexCustomFieldsToInclude,
requestedClass,
redactIssueDescriptions,
apiVersion,
Expand All @@ -61,17 +88,20 @@ export function createIssueEntity({
logger: IntegrationLogger;
fieldsById?: { [id: string]: Field };
customFieldsToInclude?: string[];
complexCustomFieldsToInclude?: string[];
requestedClass?: unknown;
redactIssueDescriptions: boolean;
apiVersion: string;
}): IssueEntity {
fieldsById = fieldsById || {};
customFieldsToInclude = customFieldsToInclude || [];
complexCustomFieldsToInclude = complexCustomFieldsToInclude || [];

const status = issue.fields.status && issue.fields.status.name;
const issueType = issue.fields.issuetype && issue.fields.issuetype.name;
const customFields: { [key: string]: any } = {};

// Extract simple custom fields
for (const [key, value] of Object.entries(issue.fields)) {
if (
key.startsWith('customfield_') &&
Expand All @@ -94,14 +124,41 @@ export function createIssueEntity({
}
}

// Handle complex custom fields
complexCustomFieldsToInclude.forEach((path) => {
const [baseFieldId, ...nestedPathParts] = path.split('.');
if (issue.fields[baseFieldId] !== undefined) {
const nestedPath = nestedPathParts.join('.');
const fieldValue = getNestedValue(issue.fields[baseFieldId], nestedPath);
if (fieldValue !== undefined) {
if (fieldsById && fieldsById[baseFieldId]) {
const baseFieldName = camelCase(fieldsById[baseFieldId].name);
const formattedPathParts = nestedPathParts.map((part) => {
const match = part.match(/^(\w+)(?:\[(\d+)\])?$/);
if (match) {
const [, key, index] = match;
if (index !== undefined) {
return `${camelCase(key)}${index}`;
}
return camelCase(key);
}
return camelCase(part);
});
const formattedPath = [baseFieldName, ...formattedPathParts].join('');
setFlatNestedValue(customFields, formattedPath, fieldValue);
}
}
}
});

if (!['string', 'undefined'].includes(typeof requestedClass)) {
logger.warn(
{ requestedClass },
'Invalid entity class. Reverting to default.',
);
requestedClass = undefined;
}

// Set issue class based on issue type or requested class
let issueClass: string | string[];

if (requestedClass) {
Expand Down Expand Up @@ -131,36 +188,26 @@ export function createIssueEntity({
}
}

// Redact issue descriptions if necessary
let entityDescription: string;
if (redactIssueDescriptions) {
if (issue.fields.description) {
issue.fields.description = {
type: 'text',
text: 'REDACTED',
};
} // don't let description leak in rawData
}
entityDescription = 'REDACTED';
} else {
entityDescription = getIssueDescription(issue, apiVersion);
}
//TEMP INT-10020
if (Object.keys(customFields).length > 0) {
logger.info(
{ customFieldsToAdd: Object.keys(customFields) },
'Adding custom fields to issue',
);
}

const entity = {
// Construct issue entity object
const entity: IssueEntity = {
_key: generateEntityKey(ISSUE_ENTITY_TYPE, issue.id),
_type: ISSUE_ENTITY_TYPE,
_class: issueClass,
_rawData: [
{
name: 'default',
rawData: issue as any,
},
],
_rawData: [{ name: 'default', rawData: issue as any }],
...customFields,
id: issue.id,
key: issue.key,
Expand Down Expand Up @@ -194,11 +241,10 @@ export function createIssueEntity({
issue.fields.components && issue.fields.components.map((c) => c.name),
priority: issue.fields.priority && issue.fields.priority.name,
};

// Add event data if requested class is provided
if (requestedClass) {
entity._rawData.push({
name: 'event',
rawData: { requestedClass },
});
entity._rawData?.push({ name: 'event', rawData: { requestedClass } });
}
return entity;
}
14 changes: 14 additions & 0 deletions src/steps/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,24 @@ export async function fetchIssues({
'Custom fields to ingest.',
);

logger.info(
{
complexCustomFields: config.complexCustomFields,
allFieldIdsAndNames: Object.values(fieldsById).map(
(field) => `${field.id}: ${field.name ?? 'undefined field name'}`,
),
},
'Complex custom fields to ingest.',
);

const issueProcessor = async (projectKey: JiraProjectKey, issue: Issue) =>
processIssue(
{
logger,
jobState,
fieldsById,
customFieldsToInclude: config.customFields,
complexCustomFieldsToInclude: config.complexCustomFields,
projectEntities,
redactIssueDescriptions: redactIssueDescriptions ?? false,
apiVersion,
Expand Down Expand Up @@ -134,6 +145,7 @@ type ProcessIssueContext = {
logger: IntegrationLogger;
fieldsById: IdFieldMap;
customFieldsToInclude: string[];
complexCustomFieldsToInclude: string[];
projectEntities: ProjectEntity[];
redactIssueDescriptions: boolean;
apiVersion: string;
Expand All @@ -153,6 +165,7 @@ async function processIssue(
logger,
jobState,
customFieldsToInclude,
complexCustomFieldsToInclude,
fieldsById,
projectEntities,
redactIssueDescriptions,
Expand All @@ -168,6 +181,7 @@ async function processIssue(
logger,
fieldsById,
customFieldsToInclude,
complexCustomFieldsToInclude,
redactIssueDescriptions,
apiVersion,
}),
Expand Down
Loading