This commit is contained in:
Dmytro Tkachenko
2026-08-29 12:55:39 +03:00
parent e637634c59
commit 9868b18818
37 changed files with 2038 additions and 0 deletions
@@ -0,0 +1,324 @@
# ADR: The Chrome extension as the single sync engine for SNOW + Jira
- **Status:** Proposed (design input for a later build — no code here)
- **Date:** 2026-08-27
- **Author:** architect
- **Supersedes:** nothing — extends the existing SNOW-only sync (`extension/`, `/api/sync`)
---
## 1. Context
Today the extension syncs **ServiceNow only**. `background.js` runs a collector in the
`rbassist.service-now.com` page (`world: MAIN`), pages the Table API same-origin using the
live session cookie + `g_ck`, maps `sc_req_item` rows to the FORGE ticket shape, and POSTs
them in 100-row chunks to `<serverUrl>/api/sync` with `Authorization: Bearer fg_…`.
Jira data currently only exists in the **seed dump** (`jira_status_map` 127 rows,
`jira_board_state` 1045 rows — see `FORGE-parity-spec.md`). It goes stale the moment the
dump is loaded. The live `tickets.jira` JSONB is typed as **`JiraInfo`** and is only ever
written by the seed path, never refreshed. **Its declared shape is not 5 fields** — it is
**6** (`server/types.ts:11-18`, mirrored in `client/src/types/ticket.types.ts:9-16`):
```ts
interface JiraInfo {
status?; statusChangedAt?; key?; url?; assignee?; // the 5 populated by seed today
movements?: { at: string; who: string }[]; // ALREADY declared — see §5
}
```
The seed only populates the first five; `movements` exists in the type but is currently
unwritten. This matters: `movements` is **not** a field we get to invent (§5).
We want **one "Sync now"** to refresh both sources so the Active tab (chart 17 Jira status
durations, chart 19/20 movements) and the future Jira board page stay live.
**Load-bearing finding — the current upsert cannot be reused for Jira as-is.**
`server/db.ts` `upsertTickets` ON CONFLICT does **`status=EXCLUDED.status`,
`state=EXCLUDED.state`, `short_desc=EXCLUDED.short_desc`, `assigned_to=EXCLUDED.assigned_to`,
`assignment_group=EXCLUDED.assignment_group`, `last_activity_at/by=EXCLUDED.…`,
`updated_at=EXCLUDED.…`** — these are **overwritten, not COALESCE-preserved**. A Jira-only
payload routed through `/api/sync` would pass `normalizeIncoming`, which defaults
`status→'active'`, `state→''`, `shortDesc→''`, `assignedTo→null`, `assignmentGroup→null`,
`last_activity_*→null`**wiping the SNOW core fields of every matched ticket** (and
mis-flipping closed RITMs back to active). Only `jira` itself is COALESCE-merged. So Jira
must **not** ride the same endpoint/upsert.
---
## 2. Decision
1. **Keep SNOW exactly as-is** (in-page same-origin session collector → `/api/sync`).
2. **Add Jira as a second transport in the same extension**, using **direct Jira Cloud REST
from the service worker** authenticated with a **Jira API token** (email + token, HTTP
Basic). No Jira browser tab required.
3. **Route Jira through a new, dedicated server endpoint `/api/sync/jira`** that performs an
**attach-only UPDATE** — it writes **only** `jira` (JSONB) and `jira_key`, keyed by
RITM `number`, and **never touches** `status/state/assignee/activity`. This sidesteps the
clobber above and gives clean partial-failure semantics.
4. **No DB shape change.** Jira status/durations/movements go **inside the existing `jira`
JSONB**, which the extension sends as one enriched object. Additive only.
5. **"Sync now" = SNOW first, then Jira** (sequential, so Jira attaches to freshly-synced
rows), each phase chunked and reporting its own count.
### Why the SNOW-session / Jira-API-key split (not one mechanism)
| | ServiceNow | Jira Cloud |
|---|---|---|
| Auth we have | Live browser **session cookie + `g_ck`** | First-class **API token** (Atlassian id.atlassian.com → API tokens) |
| Personal API token | Not reliably available / instance-policy dependent; storing SNOW creds is worse | Designed for exactly this; scoped, revocable |
| Needs a logged-in tab | **Yes** (already the case; user is in SNOW all day) | **No** — SW fetch with host permission works headless |
| CSRF | `X-UserToken: g_ck` required | Not applicable (Basic auth) |
The split is the *cheap* option on both sides: SNOW keeps the zero-secret session approach
that already works; Jira uses the mechanism Atlassian actually blesses. Forcing symmetry
(e.g. scraping a Jira tab same-origin) would add a fragile MAIN-world collector and require
the user to keep a Jira tab open — strictly worse than a token.
### Does Jira require a board id? **Yes — and support a list, default one.**
To reproduce the original board (`rapidView=13793`) and its **per-status durations +
movements**, the extension reads the **Agile REST** endpoint
`GET /rest/agile/1.0/board/{boardId}/issue` (issues in board order) plus each issue's
`changelog` (`GET /rest/api/3/issue/{key}?expand=changelog`) to reconstruct status transition
timestamps → durations/movements. **Board order and column mapping only exist per board**, so
a board id is mandatory for board-faithful output. A pure JQL search (`/rest/api/3/search`)
does *not* need a board but loses column order and the board's status→column mapping.
**Recommendation:** primary input is **one board id** (the `13793` analogue). Store it as a
**list** so a second board can be added later without a settings migration, but the UI
defaults to a single field. Provide an **optional JQL override** for power cases (e.g.
`project = XYZ AND updated >= -14d`); when JQL is set it augments the board fetch's filter,
it does not replace the board (we still need the board for column mapping).
---
## 3. Settings schema (extension options → `chrome.storage.local`)
```
{
// FORGE (unchanged names — back-compat with today's build)
serverUrl: "https://forge.mycloud.dp.ua", // FORGE API domain
token: "fg_…", // FORGE portal key, minted at Admin→Tokens, revocable
// Jira (new)
jira: {
baseUrl: "https://rocketmill.atlassian.net", // Jira Cloud site
email: "svc-forge@…", // Atlassian account email (Basic auth username)
apiToken: "ATATT…", // Jira API token (Basic auth password) — SECRET
boardIds: [13793], // list; UI defaults to one
jql: "" // optional override/filter, may be blank
closedLookbackDays: 14 // 0 = active-only (see §6)
}
}
```
- FORGE Basic-of-nothing: FORGE keeps `Authorization: Bearer <fg_ token>`.
- Jira auth header: `Authorization: Basic base64(email + ":" + apiToken)`.
- On **Save**, request host permission for **both** origins (as options.js already does for
the FORGE origin): the FORGE server origin **and** `https://<site>.atlassian.net/*`.
- A **"Test Jira"** button (mirror of the existing "Test") calls
`GET {baseUrl}/rest/api/3/myself` and reports 200/401.
---
## 4. Two transports & manifest implications
- **SNOW:** unchanged. `host_permissions: ["https://rbassist.service-now.com/*"]` stays
required; collector runs in the page; session cookie + `g_ck` do the work.
- **Jira:** fetched **from the service worker** (not a page). In MV3, a service-worker
`fetch` to a host listed in `host_permissions` is **exempt from page CORS** — the extension
is treated as a first-party origin for granted hosts, so Atlassian's (restrictive) CORS
headers are irrelevant. **This only holds with the host permission granted**; without it the
fetch is a normal cross-origin call and fails preflight.
- Add the Jira site to **`optional_host_permissions`** and request it dynamically at Save
time (same pattern as the FORGE origin today), rather than hard-coding a static
`host_permissions` entry — the site host is per-deployment and least-privilege favors
granting exactly the one instance the user configures. `optional_host_permissions` already
contains `https://*/*`, which technically covers it, but an explicit narrow grant is
cleaner and survives a future tightening of that wildcard.
- No new manifest `permissions` needed (`storage`, `scripting` already present; Jira uses
neither `scripting` nor `tabs`).
---
## 5. Dedup & merge
- **FORGE key is `number`** (RITM). No ticket dupes — that invariant is untouched; Jira never
inserts a ticket.
- **RITM ↔ Jira link:** for each Jira issue, resolve the RITM number from
**`customfield_26001`** (holds the RITM), falling back to a **summary regex** (`/RITM\d+/`),
exactly as the initial app did. Build a map `RITM number → enriched jira object`.
- **Jira issue with no RITM:** **skip it and count it.** FORGE is RITM-centric and single-user;
an unlinked Jira issue has nowhere to attach and creating a ghost ticket would violate the
"no dupes / SNOW owns the ticket row" model. Report the unlinked count in the sync status so
the user knows a link (customfield/summary) is missing. (A future "orphan Jira" store is out
of scope — not kanban, not this ADR.)
- **What lands in `jira` JSONB (superset of today's shape):**
```
{ key, url, status, statusChangedAt, assignee, // 5 existing fields, unchanged
movements, // EXISTING field — reuse shape [{ at, who }] (NOT { from, to, at })
statusDurations, // NEW additive — { "In Progress": mins, "In UAT": mins, ... }
board } // NEW additive — { id, column } for the board page
```
- **`movements` already exists in the `JiraInfo` type** (`server/types.ts:17` +
`client/src/types/ticket.types.ts:15`) as **`{ at: string; who: string }[]`**. The extension
**must populate that existing shape**, not redefine it to `{ from, to, at }`. Reconstruct
`who` from the changelog author and `at` from the transition timestamp. (I confirmed
`movements` currently has **no runtime consumer** — only the two type declarations — so a
different shape *could* be adopted, but doing so is a deliberate change to the `JiraInfo`
contract in two TS files, not "additive JSONB." **Recommendation: keep `{ at, who }`.** If the
chart genuinely needs `from`/`to`, add them as *extra optional* keys on each entry
(`{ at, who, from?, to? }`) rather than dropping `at`/`who` — that stays backward-compatible
and is still a one-line `JiraInfo` edit, called out here so the engineer expects it.)
- **`statusDurations` and `board` are genuinely new**, additive optional keys — add them to the
`JiraInfo` interface (both files) alongside `movements`. This is a **type-declaration touch,
not a DB shape change**: the `jira` column is already `JSONB` and stores whatever the object
holds. Flagging it explicitly so it isn't mistaken for a zero-code change.
- **Server (DB) shape change: none to columns.** The `jira` column is already `JSONB`. The new
endpoint replaces the whole object per ticket (the extension always sends the complete
enriched object it just computed), so there is no partial-merge ambiguity and no reshape of
any existing column. `jira_key` (existing TEXT column) is set from `jira.key` when present.
The only code-level shape edit is the additive `JiraInfo` TS interface above.
### New endpoint contract (attach-only)
```
POST /api/sync/jira (requireToken — same fg_ Bearer as /api/sync)
body: { issues: [ { number, jira: {…enriched…} }, … ] } // chunked, 100
per row: UPDATE tickets
SET jira = $2::jsonb,
jira_key = COALESCE($3, jira_key),
synced_at = NOW()
WHERE number = $1
resp: { updated: <rows hit>, unmatched: <numbers not found>, unlinked: <issues w/o RITM> }
```
Because it is an **UPDATE … WHERE number =**, a Jira payload for an RITM not yet in FORGE
simply affects 0 rows (counted as `unmatched`) — it **cannot** create a stub row or flip
`status`/`state`/`assignee`. This is the whole reason for a separate endpoint rather than
folding into `/api/sync`.
---
## 6. Sync flow & scheduling
`Sync now` (popup) → service worker `runSync()`:
1. **Phase A — SNOW** (unchanged): collect active RITMs same-origin → `POST /api/sync` in
100-chunks. On failure: **abort before Phase B** (don't attach Jira to a stale ticket set)
and report the SNOW error as today.
2. **Phase B — Jira:** for each `boardId`, fetch board issues (+ changelog), resolve RITM,
build enriched `jira` objects, → `POST /api/sync/jira` in 100-chunks. On failure: Phase A is
**already committed and intact**; surface a *warning* ("SNOW synced ✓, Jira failed: …")
rather than a hard error. Report `updated / unmatched / unlinked`.
3. Push a combined `{ state, snowCount, jiraUpdated, jiraUnmatched, at }` to `syncStatus` for
the popup.
- **Sequential, not parallel:** Jira must attach to rows SNOW just wrote.
- **Chunking:** 100 on both push directions (matches today). Jira *read* is paged by the Agile
API (`maxResults`/`startAt`, 50100) — page defensively with a hard ceiling like the SNOW
collector's `offset < 2000` guard.
- **Separate endpoint, not folded:** decided in §2/§5 — clobber-safety + independent
partial-failure reporting.
- **Scheduling:** keep **manual "Sync now"** for v1 (single-user, user is at the desk). A
`chrome.alarms` periodic sync is a trivial later add but out of scope; note that periodic
Jira sync consumes API-token rate budget even when idle.
---
## 7. Scope of tickets
- **SNOW:** stays **active-only** (the collector query is `active=true`).
- **Jira:** default **active board + a small closed lookback** (`closedLookbackDays`, e.g. 14)
so *recently* closed issues' durations/movements stay fresh for charts 17/19/20. `0` =
active-only.
- **Not** full history. Trade-offs:
- *For historical sync:* analytics freshness on old tickets.
- *Against (decisive):* Jira Cloud API-token rate limits + wall-clock cost of walking every
issue's changelog; and history is already owned by the **seed dump**
(`analytics_data.ticketsMeta` 954 rows is THE historical dataset). Live-syncing 1000+
closed issues on every "Sync now" is wasteful and slow.
- **Verdict:** *seed handles history; the extension keeps active + a short closed window.*
If someone needs a full historical refresh, that's a re-seed, not a per-click sync.
---
## 8. Security
- **Jira API token is a real secret in `chrome.storage.local`** — which is **not encrypted at
rest** and is readable by anyone with the OS user's Chrome profile on disk. Mitigations to
bake into the build:
- Use a **dedicated low-privilege Jira service account** with **read-only** project access,
not a personal admin token. Blast radius on leak = read a board.
- Store **email + API token**, never a password. API tokens are individually revocable from
Atlassian without disturbing the account.
- **Never log** the token, the `Authorization` header, or issue bodies (the SNOW collector
already treats `g_ck`/token this way — hold the same line for Jira).
- Document in options UI that the token is stored locally and to revoke it from Atlassian if
the machine is compromised.
- **FORGE `fg_` token** is already **revocable via Admin→Tokens** and `requireToken`-gated —
rotate freely; the new `/api/sync/jira` reuses the **same** Bearer, no new server secret.
- **CORS / host model:** covered in §4 — Jira REST works *because* the SW holds the Jira host
permission (CORS-exempt for granted hosts); grant exactly the one Atlassian site,
least-privilege, requested at Save.
- **Server input hardening:** `/api/sync/jira` must validate `number` is present and `jira` is
an object, and (like `sanitizeActivity`) coerce the enriched sub-fields before writing JSONB,
so a malformed `movements`/`statusDurations` can't later crash a render.
---
## 9. Alternatives considered
- **A. Fold Jira into `/api/sync`.** Rejected — the ON CONFLICT overwrites SNOW core fields
from a thin Jira payload (§1). Would require rewriting the upsert to COALESCE `status`/
`state`/`assignee`, which then breaks SNOW's own need to *set* those. A second endpoint is
simpler and safer than making one upsert serve two very different payloads.
- **B. Scrape Jira same-origin from a Jira tab (mirror SNOW).** Rejected — needs a logged-in
Jira tab, a fragile MAIN-world collector, and gives no advantage over the sanctioned API
token.
- **C. Server-side Jira sync (cron on the FORGE box, no extension).** Rejected for now — it
moves the Jira secret to the server (fine) but **splits sync into two engines**, contradicting
the goal of *one* "Sync now", and the RITM↔Jira resolution logic would live in two places.
Revisit only if we later want unattended periodic sync.
- **D. New `jira_*` columns / a `jira_status` table.** Rejected — violates "prefer additive
JSONB, no existing-column reshape"; the `jira` JSONB already exists and is the right home.
- **E. JQL-only, no board id.** Rejected as the default — loses column order/mapping the board
page needs; kept as an optional filter override.
---
## 10. Risks
- Jira **changelog walking** is the expensive part; a large board × per-issue changelog fetch
can be slow / hit rate limits. Mitigate with the closed-lookback window and paging ceilings.
- **`customfield_26001` id is instance-specific** — if it differs on this Jira site the
RITM-link falls back to summary regex only; surface the `unlinked` count so this is visible.
- **`optional_host_permissions` wildcard** (`https://*/*`) is broad; the explicit Atlassian
grant is cleaner but the wildcard's presence is a standing review flag (unrelated to this
change, worth noting to `/security`).
- Token in `chrome.storage.local` (§8) — accepted risk, mitigated by service-account scope.
---
## 11. Migration
- **DB:** **none.** No column add, no reshape — `jira` JSONB and `jira_key` already exist.
`initDB()` is untouched; no manual SQL script, no rollback needed.
- **Types:** **additive** — extend the existing `JiraInfo` interface (`server/types.ts` +
`client/src/types/ticket.types.ts`) with new optional keys `statusDurations` and `board`;
**reuse** the already-declared `movements?: { at, who }[]` (do not redefine it). Optional
keys keep every existing consumer compiling.
- **Server:** purely **additive** — new `POST /api/sync/jira` handler + a small
`attachJira(number, jira)` in `server/db.ts`. Old clients that only hit `/api/sync` keep
working unchanged.
- **Extension:** additive options fields + Phase B. An un-upgraded extension still syncs SNOW.
- **Rollout order:** ship the server endpoint first (inert until called) → then the extension
update → then populate the Jira settings. Fully backward-compatible at every step.
---
## Next
engineer — build the additive `POST /api/sync/jira` + `attachJira()` (attach-only UPDATE, §5)
first; then extend the extension options (§3 schema) and `background.js` Phase B (§6). Route
the DB touch through /dba and the Jira-token storage through /security before merge.