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();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

+27
View File
@@ -0,0 +1,27 @@
{
"manifest_version": 3,
"name": "FORGE Sync",
"description": "Sync ServiceNow RITM tickets (and optionally Jira status/links) into your FORGE portal.",
"version": "1.0.0",
"permissions": ["storage", "scripting"],
"host_permissions": ["https://rbassist.service-now.com/*"],
"optional_host_permissions": ["http://*/*", "https://*/*"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "FORGE Snow Sync",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"options_page": "options.html",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>FORGE Snow Sync — Settings</title>
<style>
:root { --p: #6366f1; --p-h: #4f46e5; --bg: #f8fafc; --sf: #fff; --bd: #e2e8f0; --tx: #0f172a; --mut: #64748b; }
* { box-sizing: border-box; margin: 0; }
body { font: 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--tx); padding: 40px 20px; }
.card { max-width: 460px; margin: 0 auto; background: var(--sf); border: 1px solid var(--bd); border-radius: 12px; padding: 28px; box-shadow: 0 4px 12px rgba(15,23,42,.06); }
h1 { font-size: 18px; margin-bottom: 4px; letter-spacing: .04em; }
p.sub { color: var(--mut); font-size: 13px; margin-bottom: 22px; }
label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--mut); margin: 16px 0 6px; }
input { width: 100%; height: 40px; padding: 0 12px; border: 1px solid var(--bd); border-radius: 8px; font-size: 14px; color: var(--tx); outline: none; }
input:focus { border-color: var(--p); box-shadow: 0 0 0 3px rgba(99,102,241,.12); }
.hint { font-size: 12px; color: var(--mut); margin-top: 6px; }
.row { display: flex; gap: 10px; margin-top: 24px; }
button { flex: 1; height: 40px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
.save { background: var(--p); color: #fff; }
.save:hover { background: var(--p-h); }
.test { background: var(--sf); color: var(--tx); border: 1px solid var(--bd); }
.test:hover { background: var(--bg); }
.feedback { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 13px; display: none; }
.feedback[data-state="ok"] { display: block; background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
.feedback[data-state="error"] { display: block; background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
</style>
</head>
<body>
<div class="card">
<h1>❄ FORGE Snow Sync</h1>
<p class="sub">Point the extension at your FORGE server and paste a sync token.</p>
<form id="form">
<label for="server-url">Server URL</label>
<input id="server-url" type="url" placeholder="https://forge.mycloud.dp.ua" autocomplete="off" />
<div class="hint">Where FORGE is reachable. No trailing slash needed.</div>
<label for="api-token">Sync token</label>
<input id="api-token" type="password" placeholder="fg_…" autocomplete="off" />
<div class="hint">Mint one in FORGE → Admin → API Tokens (or <code>npx tsx server/mint-token.ts</code>).</div>
<hr style="border:none;border-top:1px solid var(--bd);margin:24px 0 4px" />
<p class="sub" style="margin:12px 0 0">Jira (optional) — syncs status/links onto matching RITM tickets.</p>
<label for="jira-url">Jira base URL</label>
<input id="jira-url" type="url" placeholder="https://support.dataart.com" autocomplete="off" />
<div class="hint">Your Jira site (Cloud or self-hosted). Issue links use <code>&lt;baseUrl&gt;/browse/&lt;KEY&gt;</code>.</div>
<label for="jira-email">Jira email <span style="text-transform:none;font-weight:400">(Cloud only — leave blank for a PAT)</span></label>
<input id="jira-email" type="email" placeholder="you@company.com" autocomplete="off" />
<label for="jira-token">Jira API token / PAT</label>
<input id="jira-token" type="password" placeholder="ATATT… or a Personal Access Token" autocomplete="off" />
<div class="hint">With an email → Basic auth (Jira Cloud). Without → Bearer PAT (Jira Server/DC).</div>
<label for="jira-boards">Board ID(s)</label>
<input id="jira-boards" type="text" placeholder="13793" autocomplete="off" />
<div class="hint">Comma-separated agile board ids to pull. Leave blank to skip Jira sync.</div>
<div class="row">
<button type="button" class="test" id="test">Test connection</button>
<button type="submit" class="save">Save</button>
</div>
</form>
<div class="feedback" id="feedback"></div>
</div>
<script src="options.js"></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
const $ = (id) => document.getElementById(id);
const urlInput = $('server-url');
const tokenInput = $('api-token');
const jUrl = $('jira-url');
const jEmail = $('jira-email');
const jToken = $('jira-token');
const jBoards = $('jira-boards');
const feedback = $('feedback');
const stripUrl = (v) => (v || '').trim().replace(/\/+$/, '');
function originPattern(url) {
try { return `${new URL(url).origin}/*`; } catch { return null; }
}
function show(state, text) {
feedback.dataset.state = state;
feedback.textContent = text;
}
function parseBoards(v) {
return (v || '').split(',').map(s => s.trim()).filter(Boolean);
}
async function load() {
const { serverUrl, token, jira } = await chrome.storage.local.get(['serverUrl', 'token', 'jira']);
if (serverUrl) urlInput.value = serverUrl;
if (token) tokenInput.value = token;
if (jira) {
if (jira.baseUrl) jUrl.value = jira.baseUrl;
if (jira.email) jEmail.value = jira.email;
if (jira.apiToken) jToken.value = jira.apiToken;
if (Array.isArray(jira.boardIds)) jBoards.value = jira.boardIds.join(', ');
}
}
async function ensurePermission(pattern) {
try { return await chrome.permissions.request({ origins: [pattern] }); }
catch { return false; }
}
async function save(e) {
e.preventDefault();
const serverUrl = stripUrl(urlInput.value);
const token = tokenInput.value.trim();
const pattern = originPattern(serverUrl);
if (!pattern) return show('error', 'Enter a valid server URL, e.g. https://forge.mycloud.dp.ua');
if (!token) return show('error', 'Paste a sync token (starts with fg_).');
if (!(await ensurePermission(pattern))) return show('error', 'Permission for the FORGE origin was denied.');
// Jira is optional; only persist + request permission when a base URL is given.
const jiraBase = stripUrl(jUrl.value);
let jira = null;
if (jiraBase) {
const jPattern = originPattern(jiraBase);
if (!jPattern) return show('error', 'Enter a valid Jira base URL, e.g. https://support.dataart.com');
if (!(await ensurePermission(jPattern))) return show('error', 'Permission for the Jira origin was denied.');
jira = {
baseUrl: jiraBase,
email: jEmail.value.trim(),
apiToken: jToken.value.trim(),
boardIds: parseBoards(jBoards.value),
jql: '',
};
}
await chrome.storage.local.set({ serverUrl, token, jira });
show('ok', jira && jira.boardIds.length
? 'Saved. Sync from the popup — SNOW then Jira.'
: 'Saved. You can sync from the toolbar popup now.');
}
async function test() {
const serverUrl = stripUrl(urlInput.value);
const pattern = originPattern(serverUrl);
if (!pattern) return show('error', 'Enter a valid server URL first.');
if (!(await ensurePermission(pattern))) return show('error', 'Permission for that origin was denied.');
try {
const res = await fetch(`${serverUrl}/healthz`);
if (res.ok) show('ok', 'FORGE server reachable ✓');
else show('error', `Server responded ${res.status}.`);
} catch {
show('error', 'Could not reach the server.');
}
}
$('form').addEventListener('submit', save);
$('test').addEventListener('click', test);
load();
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
:root { --p: #6366f1; --p-h: #4f46e5; --bg: #f8fafc; --sf: #fff; --bd: #e2e8f0; --tx: #0f172a; --mut: #64748b; }
* { box-sizing: border-box; margin: 0; }
body { width: 300px; font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--tx); padding: 16px; }
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.mark { font-size: 18px; }
.name { font-weight: 700; letter-spacing: .12em; }
button { width: 100%; height: 38px; border: none; border-radius: 8px; background: var(--p); color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
button:hover:not(:disabled) { background: var(--p-h); }
button:disabled { opacity: .6; cursor: default; }
.status { margin-top: 12px; padding: 10px; border-radius: 8px; background: var(--sf); border: 1px solid var(--bd); font-size: 12px; color: var(--mut); min-height: 38px; display: flex; align-items: center; gap: 8px; }
.status[data-state="done"] { color: #16a34a; border-color: #bbf7d0; }
.status[data-state="error"] { color: #dc2626; border-color: #fecaca; }
.spin { width: 12px; height: 12px; border: 2px solid var(--bd); border-top-color: var(--p); border-radius: 50%; animation: s .7s linear infinite; }
@keyframes s { to { transform: rotate(360deg); } }
.opts { margin-top: 12px; text-align: center; }
.opts a { color: var(--mut); font-size: 12px; text-decoration: none; }
.opts a:hover { color: var(--p); }
</style>
</head>
<body>
<div class="head">
<span class="mark"></span>
<span class="name">FORGE SYNC</span>
</div>
<button id="sync">Sync now</button>
<div class="status" id="status" data-state="idle">Ready.</div>
<div class="opts"><a href="#" id="open-options">Server &amp; token settings →</a></div>
<script src="popup.js"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
const $ = (id) => document.getElementById(id);
const syncBtn = $('sync');
const statusEl = $('status');
function render(status) {
if (!status) { statusEl.dataset.state = 'idle'; statusEl.textContent = 'Ready.'; return; }
statusEl.dataset.state = status.state || 'idle';
const busy = status.state === 'collecting' || status.state === 'pushing';
statusEl.innerHTML = busy ? '<span class="spin"></span>' : '';
statusEl.append(status.message || '');
syncBtn.disabled = busy;
syncBtn.textContent = busy ? 'Syncing…' : 'Sync now';
}
async function init() {
const { serverUrl, token, syncStatus } = await chrome.storage.local.get(['serverUrl', 'token', 'syncStatus']);
if (!serverUrl || !token) {
render({ state: 'error', message: 'Set server URL and token in settings first.' });
} else {
render(syncStatus);
}
}
syncBtn.addEventListener('click', async () => {
render({ state: 'collecting', message: 'Starting…' });
try {
const res = await chrome.runtime.sendMessage({ type: 'SYNC' });
render(res);
} catch (err) {
render({ state: 'error', message: String(err?.message || err) });
}
});
$('open-options').addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
// Live-update while a sync runs in the worker.
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.syncStatus) render(changes.syncStatus.newValue);
});
init();