Last active
March 18, 2026 20:09
-
-
Save Sdy603/d65df88f4b0bf70c55b054cf65cd82a5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * JSM Data Center -> DX incidents.upsert | |
| * | |
| * Stateless rolling ingestion: | |
| * - Resolved incidents only | |
| * - Only incidents resolved within the last 1 year | |
| * - Only incidents not updated in the last 7 days | |
| * | |
| * Required Environment Variables: | |
| * JIRA_BASE_URL Base URL of Jira Service Management Data Center | |
| * (e.g. https://jira.company.com) | |
| * JIRA_USER Jira service account username | |
| * JIRA_TOKEN Jira password or personal access token | |
| * JIRA_PROJECT_KEYS Comma-separated list of project keys (e.g. ABC,DEF) | |
| * JIRA_INCIDENT_ISSUE_TYPES Comma-separated list of issue types representing incidents | |
| * (e.g. Incident) | |
| * DX_BASE_URL DX instance URL (e.g. https://yourinstance.getdx.net) | |
| * DX_API_KEY DX API key used for incidents.upsert | |
| * AFFECTED_SERVICES_CUSTOM_FIELD_ID Jira custom field ID for services | |
| * (e.g. customfield_12345) | |
| * | |
| * Optional (defaults shown): | |
| * PAGE_SIZE=100 | |
| * LOOKBACK_DAYS=365 | |
| * STABILIZATION_DAYS=7 | |
| * REFERENCE_PREFIX=jsm-dc | |
| * | |
| * Note: | |
| * The services field is required for this integration. You must provide | |
| * AFFECTED_SERVICES_CUSTOM_FIELD_ID. The script will not attempt to | |
| * auto-discover the field and will fail if it is not supplied. | |
| * | |
| * This intentionally keeps the dataset about one week behind in exchange | |
| * for a simple, durable, checkpoint-free sync model. | |
| * | |
| * Node.js 18+ recommended. | |
| */ | |
| function requireEnv(name) { | |
| const v = process.env[name]; | |
| if (!v) throw new Error(`Missing env var: ${name}`); | |
| return v; | |
| } | |
| function normalizeTrailingSlash(s) { | |
| return s ? s.replace(/\/+$/, "") : s; | |
| } | |
| function normalizeJiraTimestamp(s) { | |
| if (!s || typeof s !== "string") return null; | |
| return s.replace(/([+-]\d{4})$/, (t) => t.slice(0, 3) + ":" + t.slice(3)); | |
| } | |
| function removeNulls(obj) { | |
| return Object.fromEntries( | |
| Object.entries(obj).filter(([, v]) => v !== null && v !== undefined) | |
| ); | |
| } | |
| function encodeBasicAuth(user, token) { | |
| return Buffer.from(`${user}:${token}`, "utf8").toString("base64"); | |
| } | |
| function uniquePreserveOrder(arr) { | |
| const seen = new Set(); | |
| const out = []; | |
| for (const x of arr) { | |
| const key = String(x); | |
| if (!seen.has(key)) { | |
| seen.add(key); | |
| out.push(x); | |
| } | |
| } | |
| return out; | |
| } | |
| function normalizeServicesToStrings(input) { | |
| if (input === null || input === undefined) return undefined; | |
| if (typeof input === "string") { | |
| const parts = input | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean); | |
| return parts.length ? uniquePreserveOrder(parts) : undefined; | |
| } | |
| if (Array.isArray(input)) { | |
| const out = []; | |
| for (const item of input) { | |
| if (item === null || item === undefined) continue; | |
| if (typeof item === "string") { | |
| const s = item.trim(); | |
| if (s) out.push(s); | |
| continue; | |
| } | |
| if (typeof item === "number") { | |
| out.push(String(item)); | |
| continue; | |
| } | |
| if (typeof item === "object") { | |
| const v = | |
| item.value ?? | |
| item.name ?? | |
| item.displayName ?? | |
| item.id ?? | |
| item.identifier ?? | |
| null; | |
| if (v !== null && v !== undefined) { | |
| const s = String(v).trim(); | |
| if (s) out.push(s); | |
| } | |
| } | |
| } | |
| return out.length ? uniquePreserveOrder(out) : undefined; | |
| } | |
| if (typeof input === "object") { | |
| const v = | |
| input.value ?? | |
| input.name ?? | |
| input.displayName ?? | |
| input.id ?? | |
| input.identifier ?? | |
| null; | |
| if (v === null || v === undefined) return undefined; | |
| const s = String(v).trim(); | |
| return s ? [s] : undefined; | |
| } | |
| const s = String(input).trim(); | |
| return s ? [s] : undefined; | |
| } | |
| async function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| async function fetchJson( | |
| url, | |
| { method, headers, body, retryOn = [429, 500, 502, 503, 504], attempts = 4 } = {} | |
| ) { | |
| let lastErr = null; | |
| for (let i = 0; i < attempts; i++) { | |
| const res = await fetch(url, { method, headers, body }); | |
| if (!retryOn.includes(res.status)) { | |
| const text = await res.text(); | |
| if (!res.ok) throw new Error(`Request failed ${res.status}: ${text}`); | |
| return text ? JSON.parse(text) : {}; | |
| } | |
| const text = await res.text(); | |
| lastErr = new Error(`Retryable failure ${res.status}: ${text}`); | |
| await sleep(800 * (i + 1)); | |
| } | |
| throw lastErr; | |
| } | |
| async function getAffectedServicesCustomFieldId({ | |
| jiraBaseUrl, | |
| basicAuthHeader, | |
| fieldName, | |
| }) { | |
| const url = `${normalizeTrailingSlash(jiraBaseUrl)}/rest/api/2/field`; | |
| const fields = await fetchJson(url, { | |
| method: "GET", | |
| headers: { | |
| Authorization: `Basic ${basicAuthHeader}`, | |
| Accept: "application/json", | |
| }, | |
| }); | |
| const target = fieldName.trim().toLowerCase(); | |
| const match = (fields || []).find( | |
| (f) => (f?.name || "").trim().toLowerCase() === target | |
| ); | |
| if (!match?.id) { | |
| throw new Error(`Could not find Jira field named "${fieldName}".`); | |
| } | |
| return match.id; | |
| } | |
| async function jiraSearch({ | |
| jiraBaseUrl, | |
| basicAuthHeader, | |
| jql, | |
| fields, | |
| startAt, | |
| maxResults, | |
| }) { | |
| const url = `${normalizeTrailingSlash(jiraBaseUrl)}/rest/api/2/search`; | |
| return await fetchJson(url, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Basic ${basicAuthHeader}`, | |
| "Content-Type": "application/json", | |
| Accept: "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| jql, | |
| fields, | |
| startAt, | |
| maxResults, | |
| }), | |
| }); | |
| } | |
| async function dxUpsertIncident({ dxBaseUrl, dxApiKey, payload }) { | |
| const url = `${normalizeTrailingSlash(dxBaseUrl)}/api/incidents.upsert`; | |
| const res = await fetch(url, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${dxApiKey}`, | |
| "Content-Type": "application/json", | |
| Accept: "application/json", | |
| }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const text = await res.text(); | |
| if (!res.ok) throw new Error(`DX upsert failed ${res.status}: ${text}`); | |
| } | |
| async function main() { | |
| const JIRA_BASE_URL = requireEnv("JIRA_BASE_URL"); | |
| const JIRA_USER = requireEnv("JIRA_USER"); | |
| const JIRA_TOKEN = requireEnv("JIRA_TOKEN"); | |
| const JIRA_PROJECT_KEYS = requireEnv("JIRA_PROJECT_KEYS"); | |
| const JIRA_INCIDENT_ISSUE_TYPES = requireEnv("JIRA_INCIDENT_ISSUE_TYPES"); | |
| const DX_BASE_URL = requireEnv("DX_BASE_URL"); | |
| const DX_API_KEY = requireEnv("DX_API_KEY"); | |
| const PAGE_SIZE = Number(process.env.PAGE_SIZE || "100"); | |
| const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || "365"); | |
| const STABILIZATION_DAYS = Number(process.env.STABILIZATION_DAYS || "7"); | |
| const AFFECTED_SERVICES_CUSTOM_FIELD_ID = | |
| process.env.AFFECTED_SERVICES_CUSTOM_FIELD_ID; | |
| const AFFECTED_SERVICES_FIELD_NAME = | |
| process.env.AFFECTED_SERVICES_FIELD_NAME || "Affected services"; | |
| const REFERENCE_PREFIX = process.env.REFERENCE_PREFIX || "jsm-dc"; | |
| const basicAuthHeader = encodeBasicAuth(JIRA_USER, JIRA_TOKEN); | |
| const projectKeys = JIRA_PROJECT_KEYS | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean); | |
| const issueTypes = JIRA_INCIDENT_ISSUE_TYPES | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean); | |
| if (!projectKeys.length) throw new Error("JIRA_PROJECT_KEYS resolved to empty list."); | |
| if (!issueTypes.length) throw new Error("JIRA_INCIDENT_ISSUE_TYPES resolved to empty list."); | |
| const projectsPart = projectKeys.map((k) => `'${k}'`).join(","); | |
| const issueTypesPart = issueTypes.map((t) => `'${t}'`).join(","); | |
| const jql = ` | |
| project in (${projectsPart}) | |
| AND issuetype in (${issueTypesPart}) | |
| AND resolutiondate IS NOT EMPTY | |
| AND resolutiondate >= -${LOOKBACK_DAYS}d | |
| AND updated < -${STABILIZATION_DAYS}d | |
| ORDER BY resolutiondate DESC | |
| ` | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| const affectedServicesFieldId = AFFECTED_SERVICES_CUSTOM_FIELD_ID | |
| ? AFFECTED_SERVICES_CUSTOM_FIELD_ID.trim() | |
| : await getAffectedServicesCustomFieldId({ | |
| jiraBaseUrl: JIRA_BASE_URL, | |
| basicAuthHeader, | |
| fieldName: AFFECTED_SERVICES_FIELD_NAME, | |
| }); | |
| const fields = [ | |
| "summary", | |
| "created", | |
| "resolutiondate", | |
| "priority", | |
| affectedServicesFieldId, | |
| ]; | |
| console.log(`JQL: ${jql}`); | |
| console.log(`Using Affected services field id: ${affectedServicesFieldId}`); | |
| let startAt = 0; | |
| let total = null; | |
| while (total === null || startAt < total) { | |
| const page = await jiraSearch({ | |
| jiraBaseUrl: JIRA_BASE_URL, | |
| basicAuthHeader, | |
| jql, | |
| fields, | |
| startAt, | |
| maxResults: PAGE_SIZE, | |
| }); | |
| const issues = page?.issues || []; | |
| total = typeof page?.total === "number" ? page.total : total; | |
| console.log( | |
| `Fetched ${issues.length} issues (startAt=${startAt}${ | |
| total !== null ? `, total=${total}` : "" | |
| })` | |
| ); | |
| if (!issues.length) break; | |
| for (const issue of issues) { | |
| const key = issue?.key; | |
| const id = issue?.id; | |
| const f = issue?.fields || {}; | |
| const created = normalizeJiraTimestamp(f.created); | |
| const resolved = normalizeJiraTimestamp(f.resolutiondate); | |
| if (!created) { | |
| console.warn(`Skipping ${key || id}: missing created/started_at`); | |
| continue; | |
| } | |
| const services = normalizeServicesToStrings(f[affectedServicesFieldId]); | |
| const payload = removeNulls({ | |
| reference_id: `${REFERENCE_PREFIX}-${key || id}`, | |
| started_at: created, | |
| name: f.summary ? String(f.summary) : undefined, | |
| priority: f.priority?.name ? String(f.priority.name) : undefined, | |
| source_url: key | |
| ? `${normalizeTrailingSlash(JIRA_BASE_URL)}/browse/${key}` | |
| : undefined, | |
| resolved_at: resolved || undefined, | |
| services: services && services.length ? services : undefined, | |
| metadata: removeNulls({ | |
| jira_key: key ? String(key) : undefined, | |
| jira_id: id ? String(id) : undefined, | |
| }), | |
| }); | |
| await dxUpsertIncident({ | |
| dxBaseUrl: DX_BASE_URL, | |
| dxApiKey: DX_API_KEY, | |
| payload, | |
| }); | |
| console.log(`Upserted DX incident for ${key || id}`); | |
| } | |
| startAt += issues.length; | |
| if (issues.length < PAGE_SIZE) break; | |
| } | |
| console.log("Done."); | |
| } | |
| main().catch((e) => { | |
| console.error(e); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment