Skip to content

Instantly share code, notes, and snippets.

@Sdy603
Last active March 18, 2026 20:09
Show Gist options
  • Select an option

  • Save Sdy603/d65df88f4b0bf70c55b054cf65cd82a5 to your computer and use it in GitHub Desktop.

Select an option

Save Sdy603/d65df88f4b0bf70c55b054cf65cd82a5 to your computer and use it in GitHub Desktop.
#!/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