295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
// FORGE Snow Sync — service worker.
|
|
//
|
|
// Flow, on a "Sync now" request from the popup:
|
|
// 1. read { serverUrl, token } from chrome.storage.local
|
|
// 2. find (or open) a rbassist.service-now.com tab
|
|
// 3. executeScript a collector in the PAGE context — it pages the ServiceNow
|
|
// Table API same-origin (so the session cookie + g_ck CSRF token just work)
|
|
// and maps sc_req_item rows into FORGE's normalized ticket shape
|
|
// 4. POST the tickets to <serverUrl>/api/sync in chunks, Authorization: Bearer <token>
|
|
// 5. push a { state, ... } status to storage so the popup can render it
|
|
//
|
|
// The token and g_ck are never logged.
|
|
|
|
const SNOW_ORIGIN = 'https://rbassist.service-now.com';
|
|
const CHUNK = 100;
|
|
|
|
function setStatus(status) {
|
|
chrome.storage.local.set({ syncStatus: { ...status, at: Date.now() } });
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
if (msg?.type === 'SYNC') {
|
|
runSync().then(sendResponse).catch(err => sendResponse({ ok: false, error: String(err?.message || err) }));
|
|
return true; // async response
|
|
}
|
|
});
|
|
|
|
async function runSync() {
|
|
const { serverUrl, token, jira } = await chrome.storage.local.get(['serverUrl', 'token', 'jira']);
|
|
if (!serverUrl || !token) {
|
|
const err = { ok: false, state: 'error', message: 'Set the server URL and token in options first.' };
|
|
setStatus(err);
|
|
return err;
|
|
}
|
|
|
|
setStatus({ state: 'collecting', message: 'Reading ServiceNow…' });
|
|
let tickets;
|
|
try {
|
|
tickets = await collectFromSnow();
|
|
} catch (err) {
|
|
const out = { ok: false, state: 'error', message: 'ServiceNow read failed: ' + (err?.message || err) };
|
|
setStatus(out);
|
|
return out;
|
|
}
|
|
|
|
if (!tickets.length) {
|
|
const out = { ok: true, state: 'done', message: 'No active tickets found.', count: 0 };
|
|
setStatus(out);
|
|
return out;
|
|
}
|
|
|
|
setStatus({ state: 'pushing', message: `Pushing ${tickets.length} tickets…` });
|
|
let upserted = 0;
|
|
try {
|
|
for (let i = 0; i < tickets.length; i += CHUNK) {
|
|
const chunk = tickets.slice(i, i + CHUNK);
|
|
const res = await postChunk(serverUrl, token, chunk);
|
|
upserted += res.upserted ?? chunk.length;
|
|
setStatus({ state: 'pushing', message: `Pushed ${Math.min(i + CHUNK, tickets.length)}/${tickets.length}…` });
|
|
}
|
|
} catch (err) {
|
|
const out = { ok: false, state: 'error', message: 'Push failed: ' + (err?.message || err) };
|
|
setStatus(out);
|
|
return out;
|
|
}
|
|
|
|
// --- Jira phase (optional) — runs AFTER SNOW so it attaches to fresh rows ---
|
|
let jiraMsg = '';
|
|
if (jira && jira.baseUrl && jira.apiToken && Array.isArray(jira.boardIds) && jira.boardIds.length) {
|
|
setStatus({ state: 'collecting', message: 'Reading Jira…' });
|
|
try {
|
|
const items = await collectFromJira(jira);
|
|
if (items.length) {
|
|
setStatus({ state: 'pushing', message: `Pushing ${items.length} Jira links…` });
|
|
let matched = 0;
|
|
for (let i = 0; i < items.length; i += CHUNK) {
|
|
const res = await postJiraChunk(serverUrl, token, items.slice(i, i + CHUNK));
|
|
matched += res.matched ?? 0;
|
|
}
|
|
jiraMsg = ` · ${matched} Jira`;
|
|
} else {
|
|
jiraMsg = ' · 0 Jira';
|
|
}
|
|
} catch (err) {
|
|
// Jira is best-effort: a Jira failure must not fail the whole (successful) SNOW sync.
|
|
jiraMsg = ' · Jira failed: ' + (err?.message || err);
|
|
}
|
|
}
|
|
|
|
const out = { ok: true, state: 'done', message: `Synced ${upserted} tickets${jiraMsg}.`, count: upserted };
|
|
setStatus(out);
|
|
return out;
|
|
}
|
|
|
|
// --- Jira collector (direct REST from the service worker) ------------------
|
|
// Basic auth (email + token) for Jira Cloud; Bearer PAT for Jira Server/DC.
|
|
function jiraAuthHeader(jira) {
|
|
if (jira.email) return 'Basic ' + btoa(`${jira.email}:${jira.apiToken}`);
|
|
return 'Bearer ' + jira.apiToken;
|
|
}
|
|
// Resolve the RITM number for a Jira issue: the custom field first, then a
|
|
// regex over summary/description. Returns null when no RITM is linkable.
|
|
function ritmForIssue(issue) {
|
|
const f = issue.fields || {};
|
|
const cf = f.customfield_26001;
|
|
const cfStr = typeof cf === 'string' ? cf : (cf && (cf.value || cf.name)) || '';
|
|
const m = String(cfStr || `${f.summary || ''} ${f.description || ''}`).match(/RITM\d+/i);
|
|
return m ? m[0].toUpperCase() : null;
|
|
}
|
|
// From an issue's changelog, reconstruct time spent per status (ms) and the list
|
|
// of status movements. Powers chart #17 (durations) + the movement charts.
|
|
function jiraFromChangelog(issue) {
|
|
const f = issue.fields || {};
|
|
const histories = (issue.changelog && issue.changelog.histories) || [];
|
|
// status-change events, ascending by time
|
|
const events = [];
|
|
for (const h of histories) {
|
|
for (const it of (h.items || [])) {
|
|
if (it.field === 'status') {
|
|
events.push({ at: h.created, who: (h.author && (h.author.displayName || h.author.name)) || null, from: it.fromString, to: it.toString });
|
|
}
|
|
}
|
|
}
|
|
events.sort((a, b) => new Date(a.at) - new Date(b.at));
|
|
const movements = events.map(e => ({ at: e.at, who: e.who, from: e.from, to: e.to }));
|
|
|
|
const durations = {};
|
|
const add = (status, ms) => { if (status && ms > 0) durations[status] = (durations[status] || 0) + ms; };
|
|
const created = f.created ? new Date(f.created).getTime() : (events[0] ? new Date(events[0].at).getTime() : Date.now());
|
|
if (events.length) {
|
|
// status held before the first transition
|
|
add(events[0].from, new Date(events[0].at).getTime() - created);
|
|
for (let i = 0; i < events.length; i++) {
|
|
const start = new Date(events[i].at).getTime();
|
|
const end = i + 1 < events.length ? new Date(events[i + 1].at).getTime() : Date.now();
|
|
add(events[i].to, end - start);
|
|
}
|
|
} else if (f.status && f.status.name) {
|
|
add(f.status.name, Date.now() - created); // never moved
|
|
}
|
|
return { statusDurations: durations, movements };
|
|
}
|
|
|
|
async function collectFromJira(jira) {
|
|
const base = jira.baseUrl.replace(/\/+$/, '');
|
|
const auth = jiraAuthHeader(jira);
|
|
const fields = 'summary,status,assignee,updated,created,customfield_26001';
|
|
const byRitm = new Map(); // RITM → jira info (last write wins; board order)
|
|
for (const boardId of jira.boardIds) {
|
|
let startAt = 0;
|
|
for (let page = 0; page < 50; page++) { // hard cap
|
|
const url = `${base}/rest/agile/1.0/board/${encodeURIComponent(boardId)}/issue`
|
|
+ `?fields=${encodeURIComponent(fields)}&expand=changelog&maxResults=50&startAt=${startAt}`;
|
|
const res = await fetch(url, { headers: { Accept: 'application/json', Authorization: auth } });
|
|
if (res.status === 401 || res.status === 403) throw new Error('Jira auth rejected (' + res.status + ')');
|
|
if (!res.ok) throw new Error('Jira board ' + boardId + ' HTTP ' + res.status);
|
|
const body = await res.json();
|
|
const issues = body.issues || [];
|
|
for (const issue of issues) {
|
|
const ritm = ritmForIssue(issue);
|
|
if (!ritm) continue;
|
|
const f = issue.fields || {};
|
|
const cl = jiraFromChangelog(issue); // { statusDurations, movements }
|
|
byRitm.set(ritm, {
|
|
number: ritm,
|
|
jira: {
|
|
key: issue.key,
|
|
status: (f.status && f.status.name) || null,
|
|
statusChangedAt: f.updated || null,
|
|
assignee: (f.assignee && (f.assignee.displayName || f.assignee.name)) || null,
|
|
url: `${base}/browse/${issue.key}`,
|
|
statusDurations: cl.statusDurations,
|
|
movements: cl.movements,
|
|
},
|
|
});
|
|
}
|
|
if (startAt + issues.length >= (body.total ?? 0) || issues.length === 0) break;
|
|
startAt += issues.length;
|
|
}
|
|
}
|
|
return [...byRitm.values()];
|
|
}
|
|
async function postJiraChunk(serverUrl, token, items) {
|
|
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/sync/jira`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
if (res.status === 401) throw new Error('FORGE token rejected (401)');
|
|
if (!res.ok) throw new Error('FORGE /api/sync/jira HTTP ' + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
// Find an existing SNOW tab or create a background one, then run the collector.
|
|
async function collectFromSnow() {
|
|
let tabs = await chrome.tabs.query({ url: `${SNOW_ORIGIN}/*` });
|
|
let created = null;
|
|
if (!tabs.length) {
|
|
created = await chrome.tabs.create({ url: `${SNOW_ORIGIN}/now/nav/ui`, active: false });
|
|
await waitForComplete(created.id);
|
|
tabs = [created];
|
|
}
|
|
try {
|
|
const [{ result }] = await chrome.scripting.executeScript({
|
|
target: { tabId: tabs[0].id },
|
|
world: 'MAIN',
|
|
func: pageCollector,
|
|
args: [SNOW_ORIGIN],
|
|
});
|
|
if (result?.error) throw new Error(result.error);
|
|
return result?.tickets ?? [];
|
|
} finally {
|
|
if (created?.id) chrome.tabs.remove(created.id).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function waitForComplete(tabId) {
|
|
return new Promise(resolve => {
|
|
const listener = (id, info) => {
|
|
if (id === tabId && info.status === 'complete') {
|
|
chrome.tabs.onUpdated.removeListener(listener);
|
|
resolve();
|
|
}
|
|
};
|
|
chrome.tabs.onUpdated.addListener(listener);
|
|
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
|
|
});
|
|
}
|
|
|
|
// Runs in the ServiceNow page context. Pages the Table API and maps rows.
|
|
// Must be fully self-contained (serialized into the page).
|
|
async function pageCollector(origin) {
|
|
try {
|
|
const gck = window.g_ck || (window.g_user && window.g_user.g_ck) || '';
|
|
const query = 'active=true^assignment_groupLIKEMarketing Web Presence^ORDERBYDESCsys_updated_on';
|
|
const fields = [
|
|
'number', 'short_description', 'state', 'assigned_to', 'assignment_group',
|
|
'opened_at', 'sys_updated_on', 'due_date', 'sys_updated_by',
|
|
].join(',');
|
|
|
|
const tickets = [];
|
|
const pageSize = 100;
|
|
for (let offset = 0; offset < 2000; offset += pageSize) {
|
|
const url = `${origin}/api/now/table/sc_req_item`
|
|
+ `?sysparm_query=${encodeURIComponent(query)}`
|
|
+ `&sysparm_display_value=true`
|
|
+ `&sysparm_exclude_reference_link=true`
|
|
+ `&sysparm_fields=${encodeURIComponent(fields)}`
|
|
+ `&sysparm_limit=${pageSize}&sysparm_offset=${offset}`;
|
|
const res = await fetch(url, {
|
|
credentials: 'include',
|
|
headers: { Accept: 'application/json', ...(gck ? { 'X-UserToken': gck } : {}) },
|
|
});
|
|
if (res.status === 401 || res.status === 403) return { error: 'Not signed in to ServiceNow.' };
|
|
if (!res.ok) return { error: 'Table API HTTP ' + res.status };
|
|
const body = await res.json();
|
|
const rows = body.result || [];
|
|
for (const r of rows) {
|
|
tickets.push({
|
|
number: r.number,
|
|
status: 'active',
|
|
state: r.state || '',
|
|
shortDesc: r.short_description || '',
|
|
assignedTo: r.assigned_to || null,
|
|
assignmentGroup: r.assignment_group || null,
|
|
openedAt: r.opened_at || null,
|
|
dueDate: r.due_date || null,
|
|
stateChangedBy: r.sys_updated_by || null,
|
|
lastActivityAt: r.sys_updated_on || null,
|
|
lastActivityBy: r.sys_updated_by || null,
|
|
updatedAt: r.sys_updated_on || null,
|
|
link: `${origin}/sc_req_item.do?sysparm_query=number=${encodeURIComponent(r.number)}`,
|
|
activity: [],
|
|
});
|
|
}
|
|
if (rows.length < pageSize) break;
|
|
}
|
|
return { tickets };
|
|
} catch (e) {
|
|
return { error: String(e && e.message ? e.message : e) };
|
|
}
|
|
}
|
|
|
|
async function postChunk(serverUrl, token, tickets) {
|
|
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/sync`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ tickets }),
|
|
});
|
|
if (res.status === 401) throw new Error('token rejected (401)');
|
|
if (res.status === 403) throw new Error('forbidden (403)');
|
|
if (!res.ok) throw new Error('server HTTP ' + res.status);
|
|
return res.json();
|
|
}
|