Add per-request-type field maps for Webex approval cards.
Drive approval card details from requestTypeFields.json, fix Service Request type 382→194, and add discovery tooling plus tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
efc64a227c
commit
c6d3b9b531
8 changed files with 685 additions and 79 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ logs/
|
||||||
config/config.json
|
config/config.json
|
||||||
config/config.local.json
|
config/config.local.json
|
||||||
config/webex-oauth.json
|
config/webex-oauth.json
|
||||||
|
config/requestTypeFields.discovered.json
|
||||||
|
|
|
||||||
296
config/requestTypeFields.json
Normal file
296
config/requestTypeFields.json
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "Per-request-type fields to show on Webex approval notifications. Regenerate raw schemas with: node scripts/discover-request-type-fields.js",
|
||||||
|
"lastReviewed": "2026-07-28",
|
||||||
|
"serviceDesks": {
|
||||||
|
"CHANGE": "135",
|
||||||
|
"REQUEST": "136"
|
||||||
|
},
|
||||||
|
"notes": [
|
||||||
|
"Some REQUEST forms only expose summary via the JSM field API (dynamic/legacy forms). Those types use summaryOnly — do not attach personIdentity unless discovery shows those fields.",
|
||||||
|
"Request type 382 was retired in Jira; use 194 (Service Request) on desk 136.",
|
||||||
|
"fieldId 'summary' uses issue.fields.summary. Use path for nested webhook/transition data.",
|
||||||
|
"format hints: text | option | user | datetime | cmdb | multiselect | textarea"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"displayFields": [
|
||||||
|
{ "path": "transition.transitionName", "label": "Approval step" },
|
||||||
|
{ "path": "transition.from_status", "label": "From status" },
|
||||||
|
{ "path": "transition.to_status", "label": "To status" },
|
||||||
|
{ "path": "user.displayName", "label": "Moved by" },
|
||||||
|
{ "fieldId": "priority", "label": "Priority", "format": "option" },
|
||||||
|
{ "fieldId": "reporter", "label": "Reporter", "format": "user" },
|
||||||
|
{ "fieldId": "assignee", "label": "Assignee", "format": "user" },
|
||||||
|
{ "fieldId": "created", "label": "Created", "format": "datetime" },
|
||||||
|
{ "fieldId": "updated", "label": "Updated", "format": "datetime" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"personIdentity": {
|
||||||
|
"description": "Shared person block on many REQUEST access forms",
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10346", "label": "Last name" },
|
||||||
|
{ "fieldId": "customfield_10314", "label": "First name" },
|
||||||
|
{ "fieldId": "customfield_10318", "label": "Position/Title" },
|
||||||
|
{ "fieldId": "customfield_10310", "label": "Employment type", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10419", "label": "Network user name" },
|
||||||
|
{ "fieldId": "customfield_10316", "label": "Employee number" },
|
||||||
|
{ "fieldId": "customfield_10353", "label": "Department", "format": "cmdb" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"serverInfrastructure": {
|
||||||
|
"description": "Server build / decommission shared fields",
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10319", "label": "Server type", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10336", "label": "Environment(s)", "format": "multiselect" },
|
||||||
|
{ "fieldId": "customfield_10397", "label": "Server hardware type", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10334", "label": "Server location & region", "format": "cascading" },
|
||||||
|
{ "fieldId": "customfield_10418", "label": "Application list", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10320", "label": "Server name", "format": "cmdb" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"changeManagement": {
|
||||||
|
"description": "Standard CHANGE approval context",
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10287", "label": "Change category", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10259", "label": "Impact level", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10257", "label": "Implementation date", "format": "datetime" },
|
||||||
|
{ "fieldId": "customfield_10252", "label": "Business justification", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10256", "label": "Business impact / risk", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10253", "label": "Testing completed", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10292", "label": "Rollback plan", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10242", "label": "Related systems/CIs", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10302", "label": "Implementer", "format": "user" },
|
||||||
|
{ "fieldId": "customfield_10241", "label": "Related Jira key" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"summaryOnly": {
|
||||||
|
"description": "Forms where JSM API only exposes summary — show summary + description",
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "summary", "label": "Summary" },
|
||||||
|
{ "fieldId": "description", "label": "Description", "format": "textarea" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestTypes": {
|
||||||
|
"177": {
|
||||||
|
"name": "Request a Change",
|
||||||
|
"project": "CHANGE",
|
||||||
|
"serviceDeskId": "135",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["changeManagement"],
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10250", "label": "Business partner(s)", "format": "users" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"179": {
|
||||||
|
"name": "Customer Technology Production Change",
|
||||||
|
"project": "CHANGE",
|
||||||
|
"serviceDeskId": "135",
|
||||||
|
"includeCommon": true,
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10257", "label": "Production deploy date", "format": "datetime" },
|
||||||
|
{ "fieldId": "customfield_10252", "label": "Business justification", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10256", "label": "Impacted services", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10253", "label": "Testing completed", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10292", "label": "Rollback plan", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10242", "label": "Configuration items", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10250", "label": "Approvers", "format": "users" },
|
||||||
|
{ "fieldId": "customfield_10241", "label": "Related Jira key(s)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"183": {
|
||||||
|
"name": "Firewall Access",
|
||||||
|
"project": "CHANGE",
|
||||||
|
"serviceDeskId": "135",
|
||||||
|
"includeCommon": true,
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10309", "label": "Firewall request details", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10252", "label": "End result / justification", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10242", "label": "Application(s)", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10302", "label": "Implementer", "format": "user" },
|
||||||
|
{ "fieldId": "customfield_10241", "label": "Related ticket" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"186": {
|
||||||
|
"name": "Request Store Fixtures",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10261", "label": "Store number", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10689", "label": "Items requested", "format": "textarea" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"187": {
|
||||||
|
"name": "New Server Build Request",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["personIdentity", "serverInfrastructure"],
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10371", "label": "Active Directory group" },
|
||||||
|
{ "fieldId": "customfield_10244", "label": "Technical owner", "format": "users" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"188": {
|
||||||
|
"name": "DI Access",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"],
|
||||||
|
"notes": "JSM field API returns summary-only form; summary field is labeled 'Brief Summary for DI Request'"
|
||||||
|
},
|
||||||
|
"189": {
|
||||||
|
"name": "IBMi Access",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["personIdentity"],
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10352", "label": "IBMi applications", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10365", "label": "Model after user", "format": "user" },
|
||||||
|
{ "fieldId": "customfield_10326", "label": "IBMi role", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10434", "label": "Environment", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10321", "label": "DC location", "format": "multiselect" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"190": {
|
||||||
|
"name": "Server Access",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"],
|
||||||
|
"notes": "Legacy form — enrich via getIssue if more fields needed"
|
||||||
|
},
|
||||||
|
"191": {
|
||||||
|
"name": "Desktop Requests",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"],
|
||||||
|
"notes": "Summary field labeled 'Brief Description' on form"
|
||||||
|
},
|
||||||
|
"192": {
|
||||||
|
"name": "Contractor Conversion",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"]
|
||||||
|
},
|
||||||
|
"193": {
|
||||||
|
"name": "Onboard New Hire / Modify Existing User",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"],
|
||||||
|
"notes": "JSM field API returns summary-only form (portal label: Onboard New Hire)"
|
||||||
|
},
|
||||||
|
"197": {
|
||||||
|
"name": "Control M Job Request",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"],
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10255", "label": "Control-M job name" }
|
||||||
|
],
|
||||||
|
"notes": "Summary field is 'Brief Description of Control M Request' — job name and details are typically entered in summary text. customfield_10255 is shown when present on the issue."
|
||||||
|
},
|
||||||
|
"199": {
|
||||||
|
"name": "Contractor Extension",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10387", "label": "Contractor name(s)", "format": "users" },
|
||||||
|
{ "fieldId": "customfield_10353", "label": "Contractor department", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10299", "label": "Contractor manager", "format": "user" },
|
||||||
|
{ "fieldId": "customfield_10310", "label": "Employment type", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10366", "label": "Vendor name" },
|
||||||
|
{ "fieldId": "customfield_10410", "label": "Applications supported" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"200": {
|
||||||
|
"name": "Corporate Credit Card Request",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_11367", "label": "Applicant", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10401", "label": "Monthly travel frequency" },
|
||||||
|
{ "fieldId": "customfield_10373", "label": "International travel", "format": "option" },
|
||||||
|
{ "fieldId": "customfield_10354", "label": "Purchase types", "format": "textarea" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"202": {
|
||||||
|
"name": "Enterprise Access",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"]
|
||||||
|
},
|
||||||
|
"203": {
|
||||||
|
"name": "Clarity Onboarding",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"]
|
||||||
|
},
|
||||||
|
"204": {
|
||||||
|
"name": "Server Decommission Request",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["serverInfrastructure"],
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10353", "label": "Reporter department", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10328", "label": "Server not found (manual entry)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"209": {
|
||||||
|
"name": "Application Access",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["personIdentity"],
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10414", "label": "User location", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10323", "label": "Date of hire", "format": "date" },
|
||||||
|
{ "fieldId": "customfield_10365", "label": "Model after associate", "format": "user" },
|
||||||
|
{ "fieldId": "customfield_10333", "label": "Atlassian Suite", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10379", "label": "Data Insights apps", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10331", "label": "Digital", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10332", "label": "Finance", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10427", "label": "IT", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10393", "label": "HR", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10404", "label": "Supply Chain", "format": "cmdb" }
|
||||||
|
],
|
||||||
|
"notes": "High-volume type. Only include CMDB groups that are populated on the issue to avoid card clutter."
|
||||||
|
},
|
||||||
|
"211": {
|
||||||
|
"name": "Database Access",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"groups": ["summaryOnly"]
|
||||||
|
},
|
||||||
|
"194": {
|
||||||
|
"name": "Service Request",
|
||||||
|
"project": "REQUEST",
|
||||||
|
"serviceDeskId": "136",
|
||||||
|
"includeCommon": true,
|
||||||
|
"displayFields": [
|
||||||
|
{ "fieldId": "customfield_10353", "label": "Department", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10414", "label": "Location", "format": "cmdb" },
|
||||||
|
{ "fieldId": "customfield_10335", "label": "Reason for request", "format": "textarea" },
|
||||||
|
{ "fieldId": "customfield_10307", "label": "Implementation date", "format": "datetime" },
|
||||||
|
{ "fieldId": "customfield_10337", "label": "Reason for implementation date", "format": "textarea" },
|
||||||
|
{ "fieldId": "summary", "label": "Summary" },
|
||||||
|
{ "fieldId": "description", "label": "Detailed description", "format": "textarea" }
|
||||||
|
],
|
||||||
|
"notes": "Replaces retired request type 382. Transitions 81/101 inherited from 382 config — verify on first webhook."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"382": {
|
"194": {
|
||||||
"name": "Service Request",
|
"name": "Service Request",
|
||||||
"transitions": {
|
"transitions": {
|
||||||
"81": {
|
"81": {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ services:
|
||||||
- ./config/config.json:/usr/src/app/config/config.json:ro
|
- ./config/config.json:/usr/src/app/config/config.json:ro
|
||||||
- ./config/requests.json:/usr/src/app/config/requests.json:ro
|
- ./config/requests.json:/usr/src/app/config/requests.json:ro
|
||||||
- ./config/webexRooms.json:/usr/src/app/config/webexRooms.json:ro
|
- ./config/webexRooms.json:/usr/src/app/config/webexRooms.json:ro
|
||||||
|
- ./config/requestTypeFields.json:/usr/src/app/config/requestTypeFields.json:ro
|
||||||
- ./config/webex-oauth.json:/usr/src/app/config/webex-oauth.json
|
- ./config/webex-oauth.json:/usr/src/app/config/webex-oauth.json
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
129
lib/requestTypeFields.js
Normal file
129
lib/requestTypeFields.js
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const SKIP_FIELD_IDS = new Set(['attachment']);
|
||||||
|
|
||||||
|
export function loadRequestTypeFields() {
|
||||||
|
const path = './config/requestTypeFields.json';
|
||||||
|
if (!fs.existsSync(path)) {
|
||||||
|
return { common: { displayFields: [] }, groups: {}, requestTypes: {} };
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldEntry(entry, groups) {
|
||||||
|
if (typeof entry === 'string' && groups[entry]) {
|
||||||
|
return groups[entry].displayFields || [];
|
||||||
|
}
|
||||||
|
return [entry];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayFieldsForRequestType(requestTypeId, fieldMap = loadRequestTypeFields()) {
|
||||||
|
const key = String(requestTypeId);
|
||||||
|
const typeConfig = fieldMap.requestTypes?.[key];
|
||||||
|
if (!typeConfig) {
|
||||||
|
return fieldMap.common?.displayFields || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = fieldMap.groups || {};
|
||||||
|
const resolved = [];
|
||||||
|
|
||||||
|
if (typeConfig.includeCommon !== false) {
|
||||||
|
resolved.push(...(fieldMap.common?.displayFields || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const groupRef of typeConfig.groups || []) {
|
||||||
|
const groupFields = groups[groupRef]?.displayFields || [];
|
||||||
|
resolved.push(...groupFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of typeConfig.displayFields || []) {
|
||||||
|
resolved.push(...resolveFieldEntry(field, groups));
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
return resolved.filter(field => {
|
||||||
|
const dedupeKey = field.path || field.fieldId;
|
||||||
|
if (!dedupeKey || seen.has(dedupeKey) || SKIP_FIELD_IDS.has(field.fieldId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFieldValue(value, format) {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'option':
|
||||||
|
return value.value ?? value.name ?? String(value);
|
||||||
|
case 'user':
|
||||||
|
return value.displayName ?? value.emailAddress ?? String(value);
|
||||||
|
case 'users':
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return formatFieldValue(value, 'user');
|
||||||
|
}
|
||||||
|
return value.map(item => formatFieldValue(item, 'user')).filter(Boolean).join(', ');
|
||||||
|
case 'datetime':
|
||||||
|
case 'date':
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
case 'cmdb':
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(item => item.label || item.name || item.objectKey || item.value).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
return value.label || value.name || value.objectKey || String(value);
|
||||||
|
case 'multiselect':
|
||||||
|
case 'multicheckboxes':
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return value.map(item => item.value || item.label || String(item)).join(', ');
|
||||||
|
case 'cascading':
|
||||||
|
if (value.child) {
|
||||||
|
return `${value.value} → ${value.child.value}`;
|
||||||
|
}
|
||||||
|
return value.value ?? String(value);
|
||||||
|
case 'textarea':
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return value.displayName || value.value || value.name || JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldValueFromTicket(jiraTicket, fieldDef) {
|
||||||
|
if (fieldDef.path) {
|
||||||
|
return fieldDef.path.split('.').reduce((current, part) => current?.[part], jiraTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldId = fieldDef.fieldId;
|
||||||
|
if (!fieldId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldId === 'summary' || fieldId === 'description' || fieldId === 'priority' || fieldId === 'created' || fieldId === 'updated') {
|
||||||
|
const fields = jiraTicket.issue?.fields || {};
|
||||||
|
return fields[fieldId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return jiraTicket.issue?.fields?.[fieldId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDetailLinesForRequestType(jiraTicket, requestTypeId, fieldMap = loadRequestTypeFields()) {
|
||||||
|
const fields = getDisplayFieldsForRequestType(requestTypeId, fieldMap);
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (const fieldDef of fields) {
|
||||||
|
const rawValue = getFieldValueFromTicket(jiraTicket, fieldDef);
|
||||||
|
const formatted = formatFieldValue(rawValue, fieldDef.format);
|
||||||
|
if (formatted) {
|
||||||
|
lines.push({ label: fieldDef.label, value: formatted });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
88
scripts/discover-request-type-fields.js
Normal file
88
scripts/discover-request-type-fields.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Dump JSM request-type field definitions for approval card mapping.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/discover-request-type-fields.js
|
||||||
|
// Writes config/requestTypeFields.discovered.json
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const cloudId = process.env.JIRA_CLOUD_ID?.trim();
|
||||||
|
const host = cloudId
|
||||||
|
? `https://api.atlassian.com/ex/jira/${cloudId}`
|
||||||
|
: (process.env.JIRA_BASE_URL || 'https://aeo.atlassian.net').replace(/\/$/, '');
|
||||||
|
const email = process.env.JIRA_EMAIL;
|
||||||
|
const token = process.env.JIRA_API_TOKEN;
|
||||||
|
|
||||||
|
if (!email || !token) {
|
||||||
|
console.error('JIRA_EMAIL and JIRA_API_TOKEN must be set in .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deskMap = {
|
||||||
|
177: '135',
|
||||||
|
179: '135',
|
||||||
|
183: '135',
|
||||||
|
};
|
||||||
|
const defaultDesk = '136';
|
||||||
|
|
||||||
|
const requests = JSON.parse(fs.readFileSync('./config/requests.json', 'utf8'));
|
||||||
|
|
||||||
|
function summarizeField(field) {
|
||||||
|
return {
|
||||||
|
fieldId: field.fieldId,
|
||||||
|
name: field.name,
|
||||||
|
required: field.required,
|
||||||
|
type: field.jiraSchema?.type,
|
||||||
|
custom: field.jiraSchema?.custom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
_meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
serviceDesks: { CHANGE: '135', REQUEST: '136' },
|
||||||
|
source: 'GET /rest/servicedeskapi/servicedesk/{deskId}/requesttype/{id}/field',
|
||||||
|
},
|
||||||
|
requestTypes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [id, cfg] of Object.entries(requests)) {
|
||||||
|
const deskId = deskMap[id] || defaultDesk;
|
||||||
|
const url = `${host}/rest/servicedeskapi/servicedesk/${deskId}/requesttype/${id}/field`;
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
output.requestTypes[id] = {
|
||||||
|
name: cfg.name,
|
||||||
|
serviceDeskId: deskId,
|
||||||
|
error: data.errorMessage || String(response.status),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.requestTypes[id] = {
|
||||||
|
name: cfg.name,
|
||||||
|
serviceDeskId: deskId,
|
||||||
|
fields: (data.requestTypeFields || []).map(summarizeField),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = './config/requestTypeFields.discovered.json';
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
|
||||||
|
console.log(`Wrote ${outputPath}`);
|
||||||
|
|
||||||
|
for (const [id, rt] of Object.entries(output.requestTypes)) {
|
||||||
|
const count = rt.fields?.length ?? 0;
|
||||||
|
console.log(`${id} ${rt.name}: ${rt.error || `${count} fields`}`);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,58 @@
|
||||||
|
import { buildDetailLinesForRequestType } from '../lib/requestTypeFields.js';
|
||||||
|
|
||||||
|
function appendDetailLines(detailText, lines) {
|
||||||
|
for (const line of lines) {
|
||||||
|
detailText += ` - **${line.label}:** ${line.value}\n`;
|
||||||
|
}
|
||||||
|
return detailText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestTypeId(jiraTicket) {
|
||||||
|
return jiraTicket.issue.fields?.customfield_10010?.requestType?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCardPayload({ jiraTicket, cardText, detailText, assignmentText = '' }) {
|
||||||
|
const requestCard = {
|
||||||
|
type: 'AdaptiveCard',
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
version: '1.2',
|
||||||
|
body: [
|
||||||
|
{ type: 'TextBlock', text: cardText.trim(), wrap: true },
|
||||||
|
{ type: 'TextBlock', wrap: true, text: detailText.trim(), separator: true },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'Action.OpenUrl',
|
||||||
|
title: 'Open Request',
|
||||||
|
url: `https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Action.Submit',
|
||||||
|
title: 'Remove Card',
|
||||||
|
style: 'destructive',
|
||||||
|
data: { cardType: 'requestApproval', action: 'removeCard' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (assignmentText.trim()) {
|
||||||
|
requestCard.body.push({
|
||||||
|
type: 'TextBlock',
|
||||||
|
text: assignmentText.trim(),
|
||||||
|
wrap: true,
|
||||||
|
separator: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestText = cardText;
|
||||||
|
requestText += `---\n${detailText}`;
|
||||||
|
if (assignmentText.trim()) {
|
||||||
|
requestText += `---\n${assignmentText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { card: requestCard, message: requestText };
|
||||||
|
}
|
||||||
|
|
||||||
export function buildChangeApproval(jiraTicket, log) {
|
export function buildChangeApproval(jiraTicket, log) {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
let cardText = '';
|
let cardText = '';
|
||||||
|
|
@ -12,54 +67,28 @@ export function buildChangeApproval(jiraTicket, log) {
|
||||||
}
|
}
|
||||||
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
|
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
|
||||||
|
|
||||||
let detailText = '';
|
let detailText = '**Details:**\n';
|
||||||
detailText += `**Description:** ${jiraTicket.issue.fields.description}\n`;
|
if (jiraTicket.issue.fields.description) {
|
||||||
detailText += '**Details:**\n';
|
detailText += `**Description:** ${jiraTicket.issue.fields.description}\n`;
|
||||||
if (jiraTicket.issue.fields.customfield_10010) { detailText += ` - **Type:** ${jiraTicket.issue.fields.customfield_10010.requestType.name}\n`; }
|
|
||||||
if (jiraTicket.issue.fields.customfield_10080) { detailText += ` - **Risk:** ${jiraTicket.issue.fields.customfield_10080.value}\n`; }
|
|
||||||
if (jiraTicket.issue.fields.customfield_10004) { detailText += ` - **Impact:** ${jiraTicket.issue.fields.customfield_10004.value}\n`; }
|
|
||||||
if (jiraTicket.issue.fields.customfield_10256) { detailText += ` - **Business Impact:** ${jiraTicket.issue.fields.customfield_10256}\n`; }
|
|
||||||
if (jiraTicket.issue.fields.customfield_10252) { detailText += ` - **Business Justification:** ${jiraTicket.issue.fields.customfield_10252}\n`; }
|
|
||||||
if (jiraTicket.issue.fields.customfield_10257) {
|
|
||||||
const changeDate = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleDateString();
|
|
||||||
const changeTime = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
||||||
detailText += `**Implementation Date:** ${changeDate} ${changeTime}\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let assignmentText = '';
|
const requestTypeId = getRequestTypeId(jiraTicket);
|
||||||
if (jiraTicket.issue.fields.reporter) { assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`; }
|
if (requestTypeId) {
|
||||||
if (jiraTicket.issue.fields.customfield_10302) { assignmentText += `**Implementer:** ${jiraTicket.issue.fields.customfield_10302.displayName}\n`; }
|
detailText = appendDetailLines(detailText, buildDetailLinesForRequestType(jiraTicket, requestTypeId));
|
||||||
|
} else {
|
||||||
const requestCard = {
|
if (jiraTicket.issue.fields.customfield_10080) { detailText += ` - **Risk:** ${jiraTicket.issue.fields.customfield_10080.value}\n`; }
|
||||||
type: 'AdaptiveCard',
|
if (jiraTicket.issue.fields.customfield_10004) { detailText += ` - **Impact:** ${jiraTicket.issue.fields.customfield_10004.value}\n`; }
|
||||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
if (jiraTicket.issue.fields.customfield_10256) { detailText += ` - **Business Impact:** ${jiraTicket.issue.fields.customfield_10256}\n`; }
|
||||||
version: '1.2',
|
if (jiraTicket.issue.fields.customfield_10252) { detailText += ` - **Business Justification:** ${jiraTicket.issue.fields.customfield_10252}\n`; }
|
||||||
body: [
|
if (jiraTicket.issue.fields.customfield_10257) {
|
||||||
{ type: 'TextBlock', text: cardText.trim(), wrap: true },
|
const changeDate = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleDateString();
|
||||||
{ type: 'TextBlock', wrap: true, text: detailText.trim(), separator: true },
|
const changeTime = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||||
{ type: 'TextBlock', text: assignmentText.trim(), wrap: true, separator: true },
|
detailText += ` - **Implementation Date:** ${changeDate} ${changeTime}\n`;
|
||||||
],
|
}
|
||||||
actions: [
|
}
|
||||||
{
|
|
||||||
type: 'Action.OpenUrl',
|
|
||||||
title: 'Open Request',
|
|
||||||
url: `https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Action.Submit',
|
|
||||||
title: 'Remove Card',
|
|
||||||
style: 'destructive',
|
|
||||||
data: { cardType: 'requestApproval', action: 'removeCard' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let requestText = cardText;
|
|
||||||
requestText += `---\n${detailText}`;
|
|
||||||
requestText += `---\n${assignmentText}`;
|
|
||||||
|
|
||||||
log.logDebug('buildChangeApproval', `Built card for ${jiraTicket.issue.key}`);
|
log.logDebug('buildChangeApproval', `Built card for ${jiraTicket.issue.key}`);
|
||||||
resolve({ card: requestCard, message: requestText });
|
resolve(buildCardPayload({ jiraTicket, cardText, detailText }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,46 +106,22 @@ export function buildRequestApproval(jiraTicket) {
|
||||||
}
|
}
|
||||||
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
|
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
|
||||||
|
|
||||||
let detailText = '';
|
let detailText = '**Details:**\n';
|
||||||
detailText += '**Details:**\n';
|
const requestTypeId = getRequestTypeId(jiraTicket);
|
||||||
if (jiraTicket.issue.fields.customfield_10314) {
|
if (requestTypeId) {
|
||||||
|
detailText = appendDetailLines(detailText, buildDetailLinesForRequestType(jiraTicket, requestTypeId));
|
||||||
|
} else if (jiraTicket.issue.fields.customfield_10314) {
|
||||||
detailText += ` - **Person:** ${jiraTicket.issue.fields.customfield_10346}, ${jiraTicket.issue.fields.customfield_10314}`;
|
detailText += ` - **Person:** ${jiraTicket.issue.fields.customfield_10346}, ${jiraTicket.issue.fields.customfield_10314}`;
|
||||||
if (jiraTicket.issue.fields.customfield_10310) { detailText += ` (${jiraTicket.issue.fields.customfield_10310.value})`; }
|
if (jiraTicket.issue.fields.customfield_10310) { detailText += ` (${jiraTicket.issue.fields.customfield_10310.value})`; }
|
||||||
detailText += '\n';
|
detailText += '\n';
|
||||||
|
if (jiraTicket.issue.fields.customfield_10318) { detailText += ` - **Position:** ${jiraTicket.issue.fields.customfield_10318}\n`; }
|
||||||
}
|
}
|
||||||
if (jiraTicket.issue.fields.customfield_10318) { detailText += ` - **Position:** ${jiraTicket.issue.fields.customfield_10318}\n`; }
|
|
||||||
|
|
||||||
let assignmentText = '';
|
let assignmentText = '';
|
||||||
assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`;
|
if (jiraTicket.issue.fields.reporter) {
|
||||||
|
assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
const requestCard = {
|
resolve(buildCardPayload({ jiraTicket, cardText, detailText, assignmentText }));
|
||||||
type: 'AdaptiveCard',
|
|
||||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
||||||
version: '1.2',
|
|
||||||
body: [
|
|
||||||
{ type: 'TextBlock', text: cardText.trim(), wrap: true },
|
|
||||||
{ type: 'TextBlock', wrap: true, text: detailText.trim(), separator: true },
|
|
||||||
{ type: 'TextBlock', text: assignmentText.trim(), wrap: true, separator: true },
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
type: 'Action.OpenUrl',
|
|
||||||
title: 'Open Request',
|
|
||||||
url: `https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Action.Submit',
|
|
||||||
title: 'Remove Card',
|
|
||||||
style: 'destructive',
|
|
||||||
data: { cardType: 'requestApproval', action: 'removeCard' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let requestText = cardText;
|
|
||||||
requestText += `---\n${detailText}`;
|
|
||||||
requestText += `---\n${assignmentText}`;
|
|
||||||
|
|
||||||
resolve({ card: requestCard, message: requestText });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
test/requestTypeFields.test.js
Normal file
86
test/requestTypeFields.test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildDetailLinesForRequestType,
|
||||||
|
formatFieldValue,
|
||||||
|
getDisplayFieldsForRequestType,
|
||||||
|
} from '../lib/requestTypeFields.js';
|
||||||
|
|
||||||
|
const sampleTicket = {
|
||||||
|
transition: {
|
||||||
|
transitionName: 'Manager Approval',
|
||||||
|
from_status: 'New Request',
|
||||||
|
to_status: 'Pending Approval',
|
||||||
|
},
|
||||||
|
user: { displayName: 'Automation for Jira' },
|
||||||
|
issue: {
|
||||||
|
fields: {
|
||||||
|
priority: { value: 'Medium' },
|
||||||
|
reporter: { displayName: 'Savannah Davis' },
|
||||||
|
customfield_10346: 'Davis',
|
||||||
|
customfield_10314: 'Savannah',
|
||||||
|
customfield_10318: 'Analyst',
|
||||||
|
customfield_10310: { value: 'Full Time' },
|
||||||
|
customfield_10353: [{ label: 'IT Operations' }],
|
||||||
|
summary: 'Application Access for Savannah Davis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getDisplayFieldsForRequestType', () => {
|
||||||
|
it('returns fields for Application Access including common and groups', () => {
|
||||||
|
const fields = getDisplayFieldsForRequestType('209');
|
||||||
|
const labels = fields.map(f => f.label);
|
||||||
|
assert.ok(labels.includes('Reporter'));
|
||||||
|
assert.ok(labels.includes('First name'));
|
||||||
|
assert.ok(labels.includes('IT'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns change management fields for request type 177', () => {
|
||||||
|
const fields = getDisplayFieldsForRequestType('177');
|
||||||
|
const labels = fields.map(f => f.label);
|
||||||
|
assert.ok(labels.includes('Rollback plan'));
|
||||||
|
assert.ok(labels.includes('Implementation date'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses summaryOnly for legacy single-field forms', () => {
|
||||||
|
const fields = getDisplayFieldsForRequestType('211');
|
||||||
|
const labels = fields.map(f => f.label);
|
||||||
|
assert.ok(labels.includes('Summary'));
|
||||||
|
assert.ok(!labels.includes('First name'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Service Request fields for request type 194', () => {
|
||||||
|
const fields = getDisplayFieldsForRequestType('194');
|
||||||
|
const labels = fields.map(f => f.label);
|
||||||
|
assert.ok(labels.includes('Department'));
|
||||||
|
assert.ok(labels.includes('Reason for request'));
|
||||||
|
assert.ok(!labels.includes('First name'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFieldValue', () => {
|
||||||
|
it('formats option and user values', () => {
|
||||||
|
assert.equal(formatFieldValue({ value: 'High' }, 'option'), 'High');
|
||||||
|
assert.equal(formatFieldValue({ displayName: 'Jane Doe' }, 'user'), 'Jane Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats cmdb arrays', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatFieldValue([{ label: 'Store 123' }, { label: 'Store 456' }], 'cmdb'),
|
||||||
|
'Store 123, Store 456'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDetailLinesForRequestType', () => {
|
||||||
|
it('builds populated lines from webhook payload', () => {
|
||||||
|
const lines = buildDetailLinesForRequestType(sampleTicket, '209');
|
||||||
|
const byLabel = Object.fromEntries(lines.map(line => [line.label, line.value]));
|
||||||
|
|
||||||
|
assert.equal(byLabel['Approval step'], 'Manager Approval');
|
||||||
|
assert.equal(byLabel['First name'], 'Savannah');
|
||||||
|
assert.equal(byLabel['Position/Title'], 'Analyst');
|
||||||
|
assert.equal(byLabel.Department, 'IT Operations');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue