// 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 /api/sync in chunks, Authorization: Bearer // 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(); }