Add modular Jira-to-Webex approvals service with Docker deployment.
Stabilize logging, correlation IDs, service-account Jira auth, and direct approver reminder DMs while splitting the monolith into focused modules with Compose-based deployment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
0b056739e9
24 changed files with 2414 additions and 0 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
logs
|
||||
.env
|
||||
.env.local
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.md
|
||||
config/config.local.json
|
||||
24
.env.example
Normal file
24
.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Server
|
||||
PORT=1345
|
||||
SERVER_NAME=aeoApprovals
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Jira Cloud — service account (same pattern as wxccai)
|
||||
# Preferred: set JIRA_CLOUD_ID to use the api.atlassian.com gateway.
|
||||
JIRA_CLOUD_ID=
|
||||
JIRA_BASE_URL=https://aeo.atlassian.net
|
||||
JIRA_AUTH_TYPE=basic
|
||||
JIRA_EMAIL=your-bot@serviceaccount.atlassian.com
|
||||
JIRA_API_TOKEN=
|
||||
|
||||
# Webex bots
|
||||
WEBEX_TOKEN=
|
||||
JIRA_NOTIFIER_TOKEN=
|
||||
|
||||
# Webex rooms
|
||||
WEBEX_ROOM_ID=
|
||||
APPROVAL_ROOM_ID=
|
||||
SUPPORT_ROOM_ID=
|
||||
|
||||
# Only set if you have a specific TLS/certificate issue to work around
|
||||
# NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
logs/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.DS_Store
|
||||
|
||||
# Local secrets — use config.example.json + .env instead
|
||||
config/config.json
|
||||
config/config.local.json
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:20
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 1345
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 1345) + '/healthCheck').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
CMD [ "node", "index.js" ]
|
||||
8
app.js
Normal file
8
app.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
app.use(bodyParser.json({ limit: '200mb' }));
|
||||
return app;
|
||||
}
|
||||
25
config/config.example.json
Normal file
25
config/config.example.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"logLevel": "info",
|
||||
"webex": {
|
||||
"whUrl": "http://localhost:1345/",
|
||||
"botName": "AEO Approvals",
|
||||
"botUserName": "aeoapprovals@webex.bot",
|
||||
"room": {
|
||||
"title": "Pending Approvals | Testing"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"port": "1345",
|
||||
"name": "aeoApprovals"
|
||||
},
|
||||
"rooms": {
|
||||
"approval": "your-approval-room-id",
|
||||
"support": "your-support-room-id"
|
||||
},
|
||||
"jiraCloud": {
|
||||
"cloudId": "your-atlassian-cloud-id",
|
||||
"host": "https://aeo.atlassian.net",
|
||||
"authType": "basic",
|
||||
"email": "your-bot@serviceaccount.atlassian.com"
|
||||
}
|
||||
}
|
||||
226
config/requests.json
Normal file
226
config/requests.json
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
{
|
||||
"177": {
|
||||
"name": "Request a Change",
|
||||
"transitions": {
|
||||
"171": {
|
||||
"name": "Ready for Approval"
|
||||
},
|
||||
"271": {
|
||||
"name": "Emergency"
|
||||
}
|
||||
}
|
||||
},
|
||||
"193": {
|
||||
"name": "Onboard New Hire / Modify Existing User",
|
||||
"transitions": {
|
||||
"151": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"261": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"209": {
|
||||
"name": "Application Access",
|
||||
"transitions": {
|
||||
"1111": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"1011": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"202": {
|
||||
"name": "Enterprise Access",
|
||||
"transitions": {
|
||||
"1111": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"1011": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"189": {
|
||||
"name": "IBMi Access",
|
||||
"transitions": {
|
||||
"1111": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"1011": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"188": {
|
||||
"name": "DI Access",
|
||||
"transitions": {
|
||||
"81": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"171": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
},
|
||||
"101": {
|
||||
"name": "Finance Approval"
|
||||
}
|
||||
}
|
||||
},
|
||||
"183": {
|
||||
"name": "Firewall Access",
|
||||
"transitions": {
|
||||
"1071": {
|
||||
"name": "Ready for Approvals"
|
||||
}
|
||||
}
|
||||
},
|
||||
"191": {
|
||||
"name": "Desktop Requests",
|
||||
"transitions": {
|
||||
"81": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"221": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"382": {
|
||||
"name": "Service Request",
|
||||
"transitions": {
|
||||
"81": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"101": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"211": {
|
||||
"name": "Database Access",
|
||||
"transitions": {
|
||||
"981": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"991": {
|
||||
"name": "Business Approval"
|
||||
},
|
||||
"1001": {
|
||||
"name": "Technical Approval"
|
||||
},
|
||||
"1051": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"192": {
|
||||
"name": "Contractor Conversion",
|
||||
"transitions": {
|
||||
"151": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"261": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"197": {
|
||||
"name": "Control M Job Request",
|
||||
"transitions": {
|
||||
"81": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"101": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"name": "Corporate Credit Card Request",
|
||||
"transitions": {
|
||||
"11": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"121": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"203": {
|
||||
"name": "Clarity Onboarding",
|
||||
"transitions": {
|
||||
"11": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"61": {
|
||||
"name": "PM Approval"
|
||||
},
|
||||
"21": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"190": {
|
||||
"name": "Server Access",
|
||||
"transitions": {
|
||||
"11": {
|
||||
"name": "Pending Approval"
|
||||
},
|
||||
"141": {
|
||||
"name": "Pending Approval"
|
||||
}
|
||||
}
|
||||
},
|
||||
"199": {
|
||||
"name": "Contractor Extension",
|
||||
"transitions": {
|
||||
"1111": {
|
||||
"name": "Manager Approval"
|
||||
},
|
||||
"1011": {
|
||||
"name": "Escalate to Tier 2 Manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"179": {
|
||||
"name": "Customer Technology Production Change",
|
||||
"transitions": {
|
||||
"1071": {
|
||||
"name": "Ready for Approvals"
|
||||
}
|
||||
}
|
||||
},
|
||||
"204": {
|
||||
"name": "Server Decommission Request",
|
||||
"transitions": {
|
||||
"141": {
|
||||
"name": "Pending Approval"
|
||||
},
|
||||
"11": {
|
||||
"name": "Pending Approval"
|
||||
}
|
||||
}
|
||||
},
|
||||
"186": {
|
||||
"name": "Request Store Fixtures",
|
||||
"transitions": {
|
||||
"21": {
|
||||
"name": "Finance Approval"
|
||||
}
|
||||
}
|
||||
},
|
||||
"187": {
|
||||
"name": "New Server Build Request",
|
||||
"transitions": {
|
||||
"141": {
|
||||
"name": "Pending Approval"
|
||||
},
|
||||
"211": {
|
||||
"name": "Technical Approval"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
services:
|
||||
aeo-approvals:
|
||||
build: .
|
||||
container_name: aeo-approvals
|
||||
ports:
|
||||
- "${PORT:-1345}:1345"
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
PORT: ${PORT:-1345}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
SERVER_NAME: ${SERVER_NAME:-aeoApprovals}
|
||||
JIRA_CLOUD_ID: ${JIRA_CLOUD_ID:-}
|
||||
JIRA_BASE_URL: ${JIRA_BASE_URL:-https://aeo.atlassian.net}
|
||||
JIRA_AUTH_TYPE: ${JIRA_AUTH_TYPE:-basic}
|
||||
JIRA_EMAIL: ${JIRA_EMAIL:-}
|
||||
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
|
||||
volumes:
|
||||
- ./logs:/usr/src/app/logs
|
||||
- ./config/config.json:/usr/src/app/config/config.json:ro
|
||||
- ./config/requests.json:/usr/src/app/config/requests.json:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:' + (process.env.PORT || 1345) + '/healthCheck').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
64
index.js
Normal file
64
index.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import fs from 'fs';
|
||||
import { loadConfig, loadRequests, getMissingSecrets, describeJiraIdentity } from './lib/config.js';
|
||||
import { createLogger } from './lib/logger.js';
|
||||
import { createJiraClient } from './services/jiraClient.js';
|
||||
import { createJiraService } from './services/jira.js';
|
||||
import { createWebexService } from './services/webex.js';
|
||||
import { createJiraProcessor } from './services/jiraProcessor.js';
|
||||
import { createPendingApprovalsJob, cleanupOldLogs } from './jobs/pendingApprovals.js';
|
||||
import { scheduleJobs } from './jobs/cron.js';
|
||||
import { createWebhookRoutes } from './routes/webhooks.js';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const requests = loadRequests();
|
||||
const log = createLogger(config);
|
||||
|
||||
if (!fs.existsSync('./logs')) {
|
||||
fs.mkdirSync('./logs', { recursive: true });
|
||||
}
|
||||
|
||||
const missingSecrets = getMissingSecrets(config);
|
||||
if (missingSecrets.length > 0) {
|
||||
log.writeLog('warn', 'startup', `Missing configuration — set env vars or config.json: ${missingSecrets.join(', ')}`);
|
||||
}
|
||||
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
|
||||
log.writeLog('warn', 'startup', 'NODE_TLS_REJECT_UNAUTHORIZED=0 is set — TLS certificate verification is disabled');
|
||||
}
|
||||
log.logger('startup', `Jira identity: ${describeJiraIdentity(config)}`);
|
||||
|
||||
const jiraClient = createJiraClient(config);
|
||||
const jiraService = createJiraService(jiraClient, log);
|
||||
const webexService = createWebexService(config, log);
|
||||
const jiraProcessor = createJiraProcessor({ config, requests, jiraService, webexService, log });
|
||||
const pendingApprovalsJob = createPendingApprovalsJob({ jiraService, webexService, log });
|
||||
|
||||
scheduleJobs({
|
||||
runPendingApprovals: pendingApprovalsJob.runPendingApprovals,
|
||||
cleanupOldLogs: () => cleanupOldLogs(log),
|
||||
log,
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
const webhookRoutes = createWebhookRoutes({ config, jiraProcessor, webexService, log });
|
||||
webhookRoutes.registerRoutes(app);
|
||||
|
||||
const server = app.listen(config.server.port, () => {
|
||||
log.logger('startup', `${config.server.name} running on port ${config.server.port}.`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
server.close(() => {
|
||||
log.logger('shutdown', `${config.server.name} stopped!`);
|
||||
process.exit();
|
||||
});
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.logError('process', 'Unhandled promise rejection', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.logError('process', 'Uncaught exception — shutting down', err);
|
||||
process.exit(1);
|
||||
});
|
||||
18
jobs/cron.js
Normal file
18
jobs/cron.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import cron from 'node-cron';
|
||||
import { createCorrelationId, runWithCorrelationId } from '../lib/correlation.js';
|
||||
|
||||
export function scheduleJobs({ runPendingApprovals, cleanupOldLogs, log }) {
|
||||
cron.schedule('0 30 9 * * *', async function () {
|
||||
const correlationId = createCorrelationId({ routeName: 'cron' });
|
||||
await runWithCorrelationId(correlationId, async () => {
|
||||
try {
|
||||
log.logger('cron', 'Starting scheduled job');
|
||||
await runPendingApprovals();
|
||||
await cleanupOldLogs();
|
||||
log.logger('cron', 'Scheduled job completed');
|
||||
} catch (error) {
|
||||
log.logError('cron', 'Scheduled job failed', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
100
jobs/pendingApprovals.js
Normal file
100
jobs/pendingApprovals.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { formatDuration } from '../lib/util.js';
|
||||
|
||||
export function createPendingApprovalsJob({ jiraService, webexService, log }) {
|
||||
const { logger, logWarn, logError, logDebug } = log;
|
||||
|
||||
function sendApprovalReminder(personEmail, approvals) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
let msgText = 'You have the following pending approvals:\n';
|
||||
|
||||
for (const approval of approvals) {
|
||||
msgText += `- [${approval.key}](https://aeo.atlassian.net/servicedesk/customer/portal/135/${approval.key}): ${approval.summary} ${approval.duration}\n`;
|
||||
}
|
||||
|
||||
msgText += '\nYou can find these in the [Requests Pending Approvals](https://aeo.atlassian.net/servicedesk/customer/user/approvals?page=1) portal. If you have any questions or experience issues, please submit a [support request](https://aeo.atlassian.net/servicedesk/customer/portals).';
|
||||
|
||||
logDebug('sendApprovalReminder', `Sending direct reminder to ${personEmail}:\n${msgText}`);
|
||||
|
||||
webexService.sendWebexMessage({
|
||||
toPersonEmail: personEmail,
|
||||
markdown: msgText,
|
||||
})
|
||||
.then(result => resolve(result))
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
function runPendingApprovals() {
|
||||
return jiraService.searchUnapprovedRequests()
|
||||
.then(async function (tickets) {
|
||||
if (!tickets || tickets.length === 0) {
|
||||
logger('runPendingApprovals', 'No pending approval tickets found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const needApproval = {};
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.fields.customfield_10033) {
|
||||
for (const approvals of ticket.fields.customfield_10033) {
|
||||
if (approvals.approvers.length > 0 && approvals.finalDecision == 'pending') {
|
||||
for (const approver of approvals.approvers) {
|
||||
if (approver.approverDecision != 'approved' && new Date(approvals.createdDate.iso8601) < new Date(new Date() - (24 * 60 * 60 * 1000))) {
|
||||
if (!needApproval[approver.approver.emailAddress]) {
|
||||
needApproval[approver.approver.emailAddress] = [];
|
||||
}
|
||||
needApproval[approver.approver.emailAddress].push({
|
||||
key: ticket.key,
|
||||
summary: ticket.fields.summary,
|
||||
reporter: ticket.fields.reporter.displayName,
|
||||
duration: formatDuration(approvals.createdDate.iso8601),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logWarn('runPendingApprovals', `No approvals setup in customfield_10033 for ${ticket.key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const approver in needApproval) {
|
||||
await sendApprovalReminder(approver, needApproval[approver])
|
||||
.then(() => logger('runPendingApprovals', `Sent reminder to ${approver}.`))
|
||||
.catch(error => logError('runPendingApprovals', `Failed to send reminder to ${approver}`, error));
|
||||
}
|
||||
})
|
||||
.catch(error => logError('runPendingApprovals', 'Failed to run pending approvals job', error));
|
||||
}
|
||||
|
||||
return { runPendingApprovals };
|
||||
}
|
||||
|
||||
export async function cleanupOldLogs(log) {
|
||||
const { logger, logError } = log;
|
||||
const now = Date.now();
|
||||
const directory = './logs';
|
||||
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await fs.promises.readdir(directory);
|
||||
} catch (error) {
|
||||
logError('cleanupOldLogs', `Failed to read log directory ${directory}`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(directory, file);
|
||||
try {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (stats.isFile() && (now - stats.mtimeMs) > sevenDaysInMs) {
|
||||
await fs.promises.unlink(filePath);
|
||||
logger('cleanupOldLogs', `Deleted old log file: ${file}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('cleanupOldLogs', `Failed to process log file ${file}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
lib/config.js
Normal file
91
lib/config.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function envOrConfig(envKey, configValue) {
|
||||
const envValue = process.env[envKey];
|
||||
if (envValue !== undefined && envValue !== '') {
|
||||
return envValue;
|
||||
}
|
||||
return configValue;
|
||||
}
|
||||
|
||||
function resolveJiraHost(fileConfig) {
|
||||
const cloudId = envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId)?.trim();
|
||||
let baseUrl = envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net')?.trim();
|
||||
|
||||
if (cloudId) {
|
||||
return `https://api.atlassian.com/ex/jira/${cloudId}`;
|
||||
}
|
||||
|
||||
return baseUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function loadConfig() {
|
||||
const fileConfig = JSON.parse(fs.readFileSync('./config/config.json'));
|
||||
|
||||
const config = {
|
||||
...fileConfig,
|
||||
logLevel: envOrConfig('LOG_LEVEL', fileConfig.logLevel),
|
||||
server: {
|
||||
...fileConfig.server,
|
||||
port: envOrConfig('PORT', fileConfig.server?.port),
|
||||
name: envOrConfig('SERVER_NAME', fileConfig.server?.name),
|
||||
},
|
||||
webex: {
|
||||
...fileConfig.webex,
|
||||
token: envOrConfig('WEBEX_TOKEN', fileConfig.webex?.token),
|
||||
room: {
|
||||
...fileConfig.webex?.room,
|
||||
id: envOrConfig('WEBEX_ROOM_ID', fileConfig.webex?.room?.id),
|
||||
},
|
||||
},
|
||||
jiraNotifier: {
|
||||
...fileConfig.jiraNotifier,
|
||||
token: envOrConfig('JIRA_NOTIFIER_TOKEN', fileConfig.jiraNotifier?.token),
|
||||
},
|
||||
jiraCloud: {
|
||||
...fileConfig.jiraCloud,
|
||||
cloudId: envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId) || null,
|
||||
host: resolveJiraHost(fileConfig),
|
||||
siteUrl: (envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net') || '').replace(/\/$/, ''),
|
||||
authType: (envOrConfig('JIRA_AUTH_TYPE', fileConfig.jiraCloud?.authType) || 'basic').toLowerCase(),
|
||||
email: envOrConfig('JIRA_EMAIL', fileConfig.jiraCloud?.email),
|
||||
token: envOrConfig('JIRA_API_TOKEN', fileConfig.jiraCloud?.token),
|
||||
},
|
||||
rooms: {
|
||||
approval: envOrConfig(
|
||||
'APPROVAL_ROOM_ID',
|
||||
fileConfig.rooms?.approval || 'Y2lzY29zcGFyazovL3VzL1JPT00vZWQ4ZWY3OTAtMGE5NC0xMWVjLWExZDUtM2I5ZWNlNWFlNDI5'
|
||||
),
|
||||
support: envOrConfig(
|
||||
'SUPPORT_ROOM_ID',
|
||||
fileConfig.rooms?.support || 'Y2lzY29zcGFyazovL3VzL1JPT00vZWY4MmExNjAtNzYzNS0xMWU4LWFlYWMtNjNjMGMyMTExYTVm'
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadRequests() {
|
||||
return JSON.parse(fs.readFileSync('./config/requests.json'));
|
||||
}
|
||||
|
||||
export function getMissingSecrets(config) {
|
||||
const missing = [];
|
||||
if (!config.jiraCloud?.email) missing.push('JIRA_EMAIL');
|
||||
if (!config.jiraCloud?.token) missing.push('JIRA_API_TOKEN');
|
||||
if (!config.webex?.token) missing.push('WEBEX_TOKEN');
|
||||
if (!config.jiraNotifier?.token) missing.push('JIRA_NOTIFIER_TOKEN');
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function describeJiraIdentity(config) {
|
||||
const email = config.jiraCloud?.email || '(not set)';
|
||||
const host = config.jiraCloud?.host || '(not set)';
|
||||
const authType = config.jiraCloud?.authType || 'basic';
|
||||
const viaCloudId = config.jiraCloud?.cloudId ? `cloudId=${config.jiraCloud.cloudId}` : 'site URL';
|
||||
return `${email} (${authType}, ${viaCloudId}) → ${host}`;
|
||||
}
|
||||
23
lib/correlation.js
Normal file
23
lib/correlation.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const correlationStore = new AsyncLocalStorage();
|
||||
|
||||
export function createCorrelationId({ issueKey, routeName } = {}) {
|
||||
const shortId = randomUUID().split('-')[0];
|
||||
if (issueKey) {
|
||||
return `${issueKey}-${shortId}`;
|
||||
}
|
||||
if (routeName) {
|
||||
return `${routeName}-${shortId}`;
|
||||
}
|
||||
return shortId;
|
||||
}
|
||||
|
||||
export function getCorrelationId() {
|
||||
return correlationStore.getStore();
|
||||
}
|
||||
|
||||
export function runWithCorrelationId(correlationId, fn) {
|
||||
return correlationStore.run(correlationId, fn);
|
||||
}
|
||||
88
lib/logger.js
Normal file
88
lib/logger.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createCorrelationId, getCorrelationId, runWithCorrelationId } from './correlation.js';
|
||||
|
||||
const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
||||
|
||||
export function createLogger(config) {
|
||||
const currentLogLevel = LOG_LEVELS[config.logLevel?.toLowerCase()] ?? LOG_LEVELS.info;
|
||||
|
||||
function formatError(error) {
|
||||
if (!error) return 'Unknown error';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error.response?.data) {
|
||||
return `${error.message || 'Request failed'}: ${JSON.stringify(error.response.data)}`;
|
||||
}
|
||||
return error.stack || error.message || String(error);
|
||||
}
|
||||
|
||||
function correlationPrefix() {
|
||||
const correlationId = getCorrelationId();
|
||||
return correlationId ? `[${correlationId}] ` : '';
|
||||
}
|
||||
|
||||
function writeLog(level, activeFunction, message) {
|
||||
if (LOG_LEVELS[level] > currentLogLevel) return;
|
||||
const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${correlationPrefix()}${activeFunction}: ${message}`;
|
||||
if (level === 'error') console.error(line);
|
||||
else if (level === 'warn') console.warn(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
function logger(activeFunction, logLine) {
|
||||
writeLog('info', activeFunction, logLine);
|
||||
}
|
||||
|
||||
function logWarn(activeFunction, logLine) {
|
||||
writeLog('warn', activeFunction, logLine);
|
||||
}
|
||||
|
||||
function logError(activeFunction, logLine, error) {
|
||||
const detail = error !== undefined ? ` ${formatError(error)}` : '';
|
||||
writeLog('error', activeFunction, `${logLine}${detail}`);
|
||||
}
|
||||
|
||||
function logDebug(activeFunction, logLine) {
|
||||
writeLog('debug', activeFunction, logLine);
|
||||
}
|
||||
|
||||
function safeProcess(context, fn, meta = {}) {
|
||||
const correlationId = meta.correlationId || createCorrelationId(meta);
|
||||
runWithCorrelationId(correlationId, () => {
|
||||
Promise.resolve()
|
||||
.then(fn)
|
||||
.catch(error => logError(context, 'Unhandled error during async processing', error));
|
||||
});
|
||||
}
|
||||
|
||||
function logFile(provider, jsonData) {
|
||||
const d = new Date();
|
||||
const year = d.getFullYear();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const logFilePath = path.join(`./logs/${provider}-${year}${month}${day}.log`);
|
||||
const correlationId = getCorrelationId();
|
||||
const correlationLine = correlationId ? ` [${correlationId}]` : '';
|
||||
const content = `${d.toISOString()}${correlationLine}\n${JSON.stringify(jsonData)}\n`;
|
||||
|
||||
fs.appendFile(logFilePath, content, (error) => {
|
||||
if (error) {
|
||||
logError('logFile', `Failed to write ${provider} webhook log`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
logger,
|
||||
logWarn,
|
||||
logError,
|
||||
logDebug,
|
||||
safeProcess,
|
||||
logFile,
|
||||
writeLog,
|
||||
formatError,
|
||||
runWithCorrelationId,
|
||||
createCorrelationId,
|
||||
getCorrelationId,
|
||||
};
|
||||
}
|
||||
30
lib/util.js
Normal file
30
lib/util.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function formatDuration(timestamp, locale = 'en') {
|
||||
let value;
|
||||
const diff = (new Date().getTime() - new Date(timestamp).getTime()) / 1000;
|
||||
const minutes = Math.floor(diff / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(months / 12);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
|
||||
if (years > 0) {
|
||||
value = rtf.format(0 - years, 'year');
|
||||
} else if (months > 0) {
|
||||
value = rtf.format(0 - months, 'month');
|
||||
} else if (days > 0) {
|
||||
value = rtf.format(0 - days, 'day');
|
||||
} else if (hours > 0) {
|
||||
value = rtf.format(0 - hours, 'hour');
|
||||
} else if (minutes > 0) {
|
||||
value = rtf.format(0 - minutes, 'minute');
|
||||
} else {
|
||||
value = rtf.format(0 - diff, 'second');
|
||||
}
|
||||
|
||||
return `(Submitted ${value})`;
|
||||
}
|
||||
23
lib/validate.js
Normal file
23
lib/validate.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export function validateJiraWebhook(body) {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return { valid: false, error: 'Payload must be a JSON object' };
|
||||
}
|
||||
if (!body.issue?.key || typeof body.issue.key !== 'string') {
|
||||
return { valid: false, error: 'Missing or invalid issue.key' };
|
||||
}
|
||||
if (!body.issue.fields || typeof body.issue.fields !== 'object') {
|
||||
return { valid: false, error: 'Missing issue.fields' };
|
||||
}
|
||||
return { valid: true, issueKey: body.issue.key };
|
||||
}
|
||||
|
||||
export function validateSupportWebhook(body) {
|
||||
const base = validateJiraWebhook(body);
|
||||
if (!base.valid) {
|
||||
return base;
|
||||
}
|
||||
if (!body.webhookEvent || typeof body.webhookEvent !== 'string') {
|
||||
return { valid: false, error: 'Missing or invalid webhookEvent' };
|
||||
}
|
||||
return { valid: true, issueKey: base.issueKey };
|
||||
}
|
||||
1128
package-lock.json
generated
Normal file
1128
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "aeorequests",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"type": "module",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"body-parser": "^2.2.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.1.0",
|
||||
"jira.js": "^5.3.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
68
routes/webhooks.js
Normal file
68
routes/webhooks.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { validateJiraWebhook, validateSupportWebhook } from '../lib/validate.js';
|
||||
|
||||
const WEBHOOK_LOG_PROVIDERS = {
|
||||
'jira-request': 'jira',
|
||||
request: 'request',
|
||||
};
|
||||
|
||||
export function createWebhookRoutes({ config, jiraProcessor, webexService, log }) {
|
||||
const { logger, logWarn, safeProcess, logFile, runWithCorrelationId, createCorrelationId } = log;
|
||||
|
||||
function healthCheck(req, res) {
|
||||
res.status(200).json({ status: 'alive' });
|
||||
}
|
||||
|
||||
function handleJiraWebhook(routeName, req, res) {
|
||||
res.status(201).send();
|
||||
const validation = validateJiraWebhook(req.body);
|
||||
const correlationMeta = { routeName, issueKey: validation.issueKey || req.body?.issue?.key };
|
||||
|
||||
if (!validation.valid) {
|
||||
runWithCorrelationId(createCorrelationId(correlationMeta), () => {
|
||||
logWarn(routeName, `Invalid webhook payload: ${validation.error}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
safeProcess(routeName, () => {
|
||||
logFile(WEBHOOK_LOG_PROVIDERS[routeName] || routeName, req.body);
|
||||
jiraProcessor.processJiraTicket(req.body);
|
||||
}, correlationMeta);
|
||||
}
|
||||
|
||||
function registerRoutes(app) {
|
||||
app.get('/healthCheck', healthCheck);
|
||||
app.get('/heathCheck', healthCheck);
|
||||
|
||||
app.post('/jira-request', (req, res) => handleJiraWebhook('jira-request', req, res));
|
||||
app.post('/request', (req, res) => handleJiraWebhook('request', req, res));
|
||||
|
||||
app.post('/support', (req, res) => {
|
||||
res.status(201).send();
|
||||
const validation = validateSupportWebhook(req.body);
|
||||
const correlationMeta = { routeName: 'support', issueKey: validation.issueKey || req.body?.issue?.key };
|
||||
|
||||
if (!validation.valid) {
|
||||
runWithCorrelationId(createCorrelationId(correlationMeta), () => {
|
||||
logWarn('support', `Invalid webhook payload: ${validation.error}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
safeProcess('support', () => {
|
||||
logFile('support', req.body);
|
||||
logger('support', `Received support webhook for ${validation.issueKey}`);
|
||||
webexService.processHighSeverity(req.body, config.rooms.support);
|
||||
}, correlationMeta);
|
||||
});
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
log.logError('express', `${req.method} ${req.path}`, err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { registerRoutes };
|
||||
}
|
||||
122
services/approvalCards.js
Normal file
122
services/approvalCards.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
export function buildChangeApproval(jiraTicket, log) {
|
||||
return new Promise(function (resolve) {
|
||||
let cardText = '';
|
||||
cardText += `[${jiraTicket.issue.key}](https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}) - ${jiraTicket.issue.fields.status.name}\n`;
|
||||
|
||||
if (jiraTicket.issue.fields.customfield_10010) {
|
||||
if (jiraTicket.issue.fields.customfield_10010.errorMessage) {
|
||||
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.errorMessage}\n`;
|
||||
} else {
|
||||
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.requestType.name}\n`;
|
||||
}
|
||||
}
|
||||
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
|
||||
|
||||
let detailText = '';
|
||||
detailText += `**Description:** ${jiraTicket.issue.fields.description}\n`;
|
||||
detailText += '**Details:**\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 = '';
|
||||
if (jiraTicket.issue.fields.reporter) { assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`; }
|
||||
if (jiraTicket.issue.fields.customfield_10302) { assignmentText += `**Implementer:** ${jiraTicket.issue.fields.customfield_10302.displayName}\n`; }
|
||||
|
||||
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 },
|
||||
{ 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}`;
|
||||
|
||||
log.logDebug('buildChangeApproval', `Built card for ${jiraTicket.issue.key}`);
|
||||
resolve({ card: requestCard, message: requestText });
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRequestApproval(jiraTicket) {
|
||||
return new Promise(function (resolve) {
|
||||
let cardText = '';
|
||||
cardText += `[${jiraTicket.issue.key}](https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}) - ${jiraTicket.issue.fields.status.name}\n`;
|
||||
|
||||
if (jiraTicket.issue.fields.customfield_10010) {
|
||||
if (jiraTicket.issue.fields.customfield_10010.errorMessage) {
|
||||
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.errorMessage}\n`;
|
||||
} else {
|
||||
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.requestType.name}\n`;
|
||||
}
|
||||
}
|
||||
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
|
||||
|
||||
let detailText = '';
|
||||
detailText += '**Details:**\n';
|
||||
if (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})`; }
|
||||
detailText += '\n';
|
||||
}
|
||||
if (jiraTicket.issue.fields.customfield_10318) { detailText += ` - **Position:** ${jiraTicket.issue.fields.customfield_10318}\n`; }
|
||||
|
||||
let assignmentText = '';
|
||||
assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`;
|
||||
|
||||
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 },
|
||||
{ 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 });
|
||||
});
|
||||
}
|
||||
62
services/jira.js
Normal file
62
services/jira.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { sleep } from '../lib/util.js';
|
||||
|
||||
export function createJiraService(jiraClient, log) {
|
||||
const { logger, logError } = log;
|
||||
|
||||
function buildApprovers(issueKey) {
|
||||
return new Promise(async function (resolve, reject) {
|
||||
const startTime = new Date().getTime();
|
||||
logger(`buildApprovers(${issueKey})`, 'Building approval list');
|
||||
const approvalList = [];
|
||||
await sleep(10000);
|
||||
|
||||
jiraClient.issues.getIssue({ issueIdOrKey: issueKey })
|
||||
.then(issueApprovers => {
|
||||
for (const approvals of issueApprovers.fields.customfield_10033) {
|
||||
for (const approver of approvals.approvers) {
|
||||
if (approver.approverDecision == 'pending') {
|
||||
approvalList.push(approver.approver.emailAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger('buildApprovers', `${issueKey}: Found ${approvalList.length} approvers pending. (${new Date().getTime() - startTime}ms)`);
|
||||
resolve(approvalList);
|
||||
})
|
||||
.catch(error => {
|
||||
logError('buildApprovers', `${issueKey}: Error looking up approvers`, error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function searchUnapprovedRequests() {
|
||||
try {
|
||||
const response = await jiraClient.issueSearch.searchForIssuesUsingJqlEnhancedSearchPost({
|
||||
jql: 'status in (Escalated, "Pending Approval", "Technical Approval", "Business Approval", "Technical / IT Approval")',
|
||||
maxResults: 200,
|
||||
fields: ['summary', 'status', 'created', 'reporter', 'updated', 'comment', 'customfield_10032', 'customfield_10033'],
|
||||
});
|
||||
|
||||
logger('searchUnapprovedRequests', `Total matching issues: ${response.issues?.total}`);
|
||||
logger('searchUnapprovedRequests', `Issues found: ${response.issues?.length || 0}`);
|
||||
|
||||
if (response.issues && response.issues.length > 0) {
|
||||
response.issues.forEach(issue => {
|
||||
logger('searchUnapprovedRequests', `- ${issue.key}: ${issue.fields.summary || 'No summary'} (Status: ${issue.fields.status?.name || 'Unknown'})`);
|
||||
});
|
||||
} else {
|
||||
logger('searchUnapprovedRequests', 'No issues found matching the JQL.');
|
||||
}
|
||||
|
||||
return response.issues || [];
|
||||
} catch (error) {
|
||||
logError('searchUnapprovedRequests', 'Jira search failed', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buildApprovers,
|
||||
searchUnapprovedRequests,
|
||||
};
|
||||
}
|
||||
21
services/jiraClient.js
Normal file
21
services/jiraClient.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Version3Client } from 'jira.js';
|
||||
|
||||
export function createJiraClient(config) {
|
||||
const jira = config.jiraCloud;
|
||||
const clientConfig = { host: jira.host };
|
||||
|
||||
if (jira.authType === 'bearer') {
|
||||
clientConfig.authentication = {
|
||||
oauth2: { accessToken: jira.token },
|
||||
};
|
||||
} else {
|
||||
clientConfig.authentication = {
|
||||
basic: {
|
||||
email: jira.email,
|
||||
apiToken: jira.token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return new Version3Client(clientConfig);
|
||||
}
|
||||
84
services/jiraProcessor.js
Normal file
84
services/jiraProcessor.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { buildChangeApproval, buildRequestApproval } from './approvalCards.js';
|
||||
|
||||
export function createJiraProcessor({ config, requests, jiraService, webexService, log }) {
|
||||
const { logger, logWarn, logError } = log;
|
||||
const approvalRoomId = config.rooms.approval;
|
||||
|
||||
function handleApprovalNotifications(issueKey, jiraTicket, buildApprovalFn) {
|
||||
buildApprovalFn(jiraTicket, log)
|
||||
.then(approvalCard => jiraService.buildApprovers(issueKey).then(approvers => ({ approvalCard, approvers })))
|
||||
.then(({ approvalCard, approvers }) => {
|
||||
const cardPayload = {
|
||||
text: approvalCard.message,
|
||||
attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: approvalCard.card }],
|
||||
};
|
||||
for (const approver of approvers) {
|
||||
logger('processJiraTicket', `Notifying ${issueKey} --> ${approver}`);
|
||||
webexService.sendTicketCard(issueKey, { toPersonEmail: approver, ...cardPayload });
|
||||
}
|
||||
if (approvers.length > 0) {
|
||||
webexService.sendTicketCard(issueKey, { roomId: approvalRoomId, ...cardPayload });
|
||||
}
|
||||
})
|
||||
.catch(error => logError('processJiraTicket', `Failed approval notifications for ${issueKey}`, error));
|
||||
}
|
||||
|
||||
function processJiraTicket(jiraTicket) {
|
||||
if (!jiraTicket?.issue?.key) {
|
||||
logError('processJiraTicket', 'Missing issue key in webhook payload');
|
||||
return;
|
||||
}
|
||||
|
||||
const issueKey = jiraTicket.issue.key;
|
||||
let requestType;
|
||||
let transitionId;
|
||||
|
||||
logger('processJiraTicket', `Processing ${issueKey}.`);
|
||||
|
||||
if (jiraTicket.transition) {
|
||||
logger('processJiraTicket', `${issueKey} Transition: ${jiraTicket.transition.transitionId}.`);
|
||||
const requestTypeField = jiraTicket.issue.fields?.customfield_10010;
|
||||
if (requestTypeField?.requestType?.id) {
|
||||
requestType = requestTypeField.requestType.id;
|
||||
transitionId = jiraTicket.transition.transitionId;
|
||||
} else {
|
||||
logWarn('processJiraTicket', `${issueKey} - missing requestType: ${JSON.stringify(requestTypeField)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!issueKey || !requestType || !requests[requestType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger('processJiraTicket', `${issueKey} RequestType: ${requestType} Requests?: ${JSON.stringify(requests[requestType])}`);
|
||||
|
||||
if (requests[requestType].transitions[transitionId]) {
|
||||
if (jiraTicket.issue.fields.project.key == 'CHANGE') {
|
||||
logger('processJiraTicket', `${issueKey} is a CHANGE.`);
|
||||
handleApprovalNotifications(issueKey, jiraTicket, buildChangeApproval);
|
||||
} else if (jiraTicket.issue.fields.project.key == 'REQUEST') {
|
||||
logger('processJiraTicket', `${issueKey} is a REQUEST.`);
|
||||
handleApprovalNotifications(issueKey, jiraTicket, buildRequestApproval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jiraTicket.changelog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const approvalStatuses = ['Pending Approval', 'Emergency Approval', 'Waiting for approval'];
|
||||
for (const change of jiraTicket.changelog.items) {
|
||||
if (change.field != 'Approvers' || !approvalStatuses.includes(jiraTicket.issue.fields.status.name)) {
|
||||
continue;
|
||||
}
|
||||
if (jiraTicket.issue.fields.project.key == 'CHANGE') {
|
||||
handleApprovalNotifications(issueKey, jiraTicket, buildChangeApproval);
|
||||
} else {
|
||||
handleApprovalNotifications(issueKey, jiraTicket, buildRequestApproval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { processJiraTicket };
|
||||
}
|
||||
121
services/webex.js
Normal file
121
services/webex.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import fetch from 'node-fetch';
|
||||
|
||||
export function createWebexService(config, log) {
|
||||
const { logger, logError } = log;
|
||||
|
||||
function handleWebexResponse(context, issueKey, response, responseText) {
|
||||
if (!response.ok) {
|
||||
logError(context, `Webex API error for ${issueKey} (HTTP ${response.status})`, responseText);
|
||||
return false;
|
||||
}
|
||||
logger(context, `Successfully sent notification for ${issueKey}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendTicketCard(issueKey, body) {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.webex.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
redirect: 'follow',
|
||||
};
|
||||
|
||||
fetch('https://webexapis.com/v1/messages', requestOptions)
|
||||
.then(response => response.text().then(text => ({ response, text })))
|
||||
.then(({ response, text }) => handleWebexResponse('sendTicketCard', issueKey, response, text))
|
||||
.catch(error => logError('sendTicketCard', `Network error sending to Webex for ${issueKey}`, error));
|
||||
}
|
||||
|
||||
function sendWebexMessage(body) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.webex.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
redirect: 'follow',
|
||||
};
|
||||
|
||||
fetch('https://webexapis.com/v1/messages', requestOptions)
|
||||
.then(response => response.text().then(text => ({ response, text })))
|
||||
.then(({ response, text }) => {
|
||||
if (!response.ok) {
|
||||
return reject(new Error(`Webex API error (HTTP ${response.status}): ${text}`));
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(text));
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON response from Webex: ${text}`));
|
||||
}
|
||||
})
|
||||
.catch(error => reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
function sendHighPriorityMessage(body) {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.jiraNotifier.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
redirect: 'follow',
|
||||
};
|
||||
|
||||
fetch('https://webexapis.com/v1/messages', requestOptions)
|
||||
.then(response => response.text().then(text => ({ response, text })))
|
||||
.then(({ response, text }) => handleWebexResponse('sendHighPriorityMessage', body.roomId || 'unknown', response, text))
|
||||
.catch(error => logError('sendHighPriorityMessage', 'Network error sending to Webex', error));
|
||||
}
|
||||
|
||||
function processHighSeverity(jiraTicket, roomId) {
|
||||
const jiraKey = jiraTicket.issue.key;
|
||||
|
||||
if (jiraTicket.webhookEvent == 'comment_created' && jiraTicket.comment) {
|
||||
const action = jiraTicket.comment.created == jiraTicket.comment.updated ? 'added a comment' : 'edited a comment';
|
||||
const userName = jiraTicket.comment.updateAuthor.displayName;
|
||||
const projectKey = jiraTicket.issue.fields.project.name;
|
||||
const comment = jiraTicket.comment.body;
|
||||
sendHighPriorityMessage({
|
||||
markdown: `${userName} ${action} on the [${jiraKey}](https://aeo.atlassian.net/browse/${jiraKey}) issue in the ${projectKey} project to read as follows:\n${comment}`,
|
||||
roomId,
|
||||
});
|
||||
}
|
||||
|
||||
if (jiraTicket.webhookEvent == 'jira:issue_updated' && jiraTicket.changelog) {
|
||||
const userName = jiraTicket.user.displayName;
|
||||
const statuses = ['status', 'Severity', 'priority', 'Severity (migrated)'];
|
||||
let text = '';
|
||||
let addDescription = 0;
|
||||
|
||||
for (const change of jiraTicket.changelog.items) {
|
||||
if (statuses.includes(change.field)) {
|
||||
text += `${userName} changed ${change.field} from ${change.fromString} to ${change.toString} for issue [${jiraKey}](https://aeo.atlassian.net/browse/${jiraKey}).\n`;
|
||||
}
|
||||
if (change.field == 'Severity' || change.field == 'priority') {
|
||||
addDescription = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (addDescription > 0) {
|
||||
text += `Description / Details: ${jiraTicket.issue.fields.description}\n`;
|
||||
}
|
||||
|
||||
log.logDebug('processHighSeverity', `Sending high-priority update for ${jiraKey}`);
|
||||
sendHighPriorityMessage({ markdown: text, roomId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sendTicketCard,
|
||||
sendWebexMessage,
|
||||
sendHighPriorityMessage,
|
||||
processHighSeverity,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue