This commit is contained in:
Dmytro Tkachenko
2026-08-29 11:59:28 +03:00
commit 28d817ebe9
147 changed files with 17534 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
// 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();
}