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