From 034e657fcbb984f129fc475d994f41dfcfc0ac83 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Tue, 7 Jul 2026 11:20:41 -0400 Subject: [PATCH] feat(close): programmatic SS-ticket closure for the CC agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the app so a Contact Center AI agent can close SS tickets end-to-end (customer-confirmed fix, cancellation, duplicate) without a human touch — the natural next step after ticket creation landed. The SS Resolved (161) workflow validator requires four fields on the ticket at execution time (components + 3 CMDB customfields for Business Service / System / Cause). The transition screen itself only exposes resolution, so the closer has to PUT these into the ticket first and *then* fire the transition. This change wires up the whole flow with sensible per-subType defaults, so the AI can close a typical ticket with just { subType, comment }. Design (see Forgejo #10 for the full write-up): - Generalized services/jira/storesCache into a reusable factory createAssetsObjectCache. Same in-memory Map + on-disk JSON snapshot + startAt/maxResults pagination + personal-PAT auth. Store cache becomes one instance; three new caches join it for the CMDB validator fields (businessServices=100, systems=103, causes=107 — all in schema 68, so the same permission workaround from Forgejo #8 keeps working). - Boot-time init warms all four caches in parallel; a new services/jira/caches.js barrel exposes them for lifecycle (init) and by-name lookup (admin routes). Full sync takes ~5s in the tenant (2661 stores dominate — the three CMDB caches together hold ~653 entries and finish in ~1s). - Admin surface rebuilt around a plural /admin/caches path: GET /admin/caches — status of all four GET /admin/caches/:name/status — one cache POST /admin/caches/:name/refresh — force resync of one POST /admin/caches/refreshAll — parallel refresh Old /admin/storesCache/{status,refresh} are kept as aliases. - Config renamed: config.storesCache -> config.caches (dir instead of path; filenames auto-derived per cache). Env vars renamed to CACHES_* (dir/refresh-hours/stale-hours/page-size/max-pages). .env.example updated. - ssCloseDefaults.js maps every supported SS subType to a {component, businessService, system, cause} tuple. Values verified against the live caches so they resolve at runtime. Falls back to a safe __default__ tuple (Help Desk / Store Technology / "I can't find my option - Misc" / Unknown) for un-mapped subTypes — those catch-all values are the tenant's designed "I don't know" escape valves. - issues.js closeTicket completely reworked: * setSSValidatorFields helper: reads current ticket state, resolves each of the four fields via caller > existing-on-ticket > default, PUTs them using the Cloud CMDB shape [{id: ":"}] (same fix as Forgejo #8 for Store Number). * Only writes fields that need writing; preserves human triage by default (preserveExistingClassification=true). * Fixes Forgejo #9: only sends the resolution field when the chosen transition targets statusCategory=done. Non-done transitions (e.g. "Waiting for customer") no longer 400 on "Field 'resolution' cannot be set." - closeHelpers.js adds three CC-agent intent wrappers: confirmFixed(key, {subType, ...}) → resolution=Done customerCancelled(key, {subType, ...}) → resolution=Won't Do markDuplicate(key, {primaryKey, ...}) → resolution=Duplicate + formal Duplicate issueLink to the primary Each posts a standardized internal audit-trail comment ("Closed via WxCC AI agent: …") that documents the automated action for the humans who inherit the ticket. - New routes: POST /ticket/:key/{confirmFixed,customerCancelled, duplicate} plus an expanded body on the existing /close route. Verified end-to-end: POST /ticket/SS-20948/confirmFixed with just subType=Report a Technology issue transitioned the ticket to Resolved with all four validator fields populated from defaults (Help Desk / Store Technology / I can't find my option - Misc / Unknown), plus resolution=Done. Direct Jira REST GET confirms every field persisted. Closes Forgejo #9. Refs Forgejo #10. Co-authored-by: Cursor --- .env.example | 14 +- README.md | 73 ++++- src/app.js | 16 +- src/config/index.js | 27 +- src/config/ssCloseDefaults.js | 142 ++++++++++ src/routes/wxccRoutes.js | 183 ++++++++++-- src/services/jira/assetsObjectCache.js | 309 ++++++++++++++++++++ src/services/jira/businessServicesCache.js | 32 +++ src/services/jira/caches.js | 23 ++ src/services/jira/causesCache.js | 32 +++ src/services/jira/closeHelpers.js | 163 +++++++++++ src/services/jira/issues.js | 273 +++++++++++++++++- src/services/jira/storesCache.js | 315 ++------------------- src/services/jira/systemsCache.js | 30 ++ src/services/jiraService.js | 34 ++- 15 files changed, 1307 insertions(+), 359 deletions(-) create mode 100644 src/config/ssCloseDefaults.js create mode 100644 src/services/jira/assetsObjectCache.js create mode 100644 src/services/jira/businessServicesCache.js create mode 100644 src/services/jira/caches.js create mode 100644 src/services/jira/causesCache.js create mode 100644 src/services/jira/closeHelpers.js create mode 100644 src/services/jira/systemsCache.js diff --git a/.env.example b/.env.example index e074db9..f71e6b0 100644 --- a/.env.example +++ b/.env.example @@ -65,14 +65,16 @@ JIRA_STORE_CUSTOM_FIELD_ID=customfield_10261 ASSETS_SYNC_EMAIL=you@ae.com ASSETS_SYNC_TOKEN=REPLACE_ME -# Where the local cache lives on disk (JSON). Gitignored. Regenerable via -# POST /api/wxccai/admin/storesCache/refresh. -STORES_CACHE_PATH=./data/stores.json -# How often to run a full resync (hours). 0 disables the scheduler. -STORES_CACHE_REFRESH_HOURS=24 +# Shared settings for all Assets object caches (stores, business services, +# systems, causes). Each cache is a Map backed by a JSON file at +# ${CACHES_DIR}/${cacheName}.json. Regenerable via +# POST /api/wxccai/admin/caches/refreshAll (or refresh a single cache). +CACHES_DIR=./data +# How often to run a full resync per cache (hours). 0 disables the scheduler. +CACHES_REFRESH_HOURS=24 # Cache is considered "stale" after this many hours; boot-time refresh fires # if the on-disk snapshot is older than this. -STORES_CACHE_STALE_AFTER_HOURS=48 +CACHES_STALE_AFTER_HOURS=48 # --- xAI (Grok) --- XAI_API_KEY=REPLACE_ME diff --git a/README.md b/README.md index 64d1562..33e5048 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,10 @@ Base path: `/api/wxccai`. - `PATCH /ticket/:key` — body: `{ summary?, description?, priority?, labels?, assigneeAccountId?, additional? }` - `POST /ticket/:key/comment` — body: `{ text, internal? }` -- `POST /ticket/:key/close` — body: `{ transitionName?, resolution?, comment?, internal? }` (auto-picks the first "done" transition when `transitionName` omitted). +- `POST /ticket/:key/close` — body: `{ transitionName?, resolution?, comment?, internal?, component?, businessService?, system?, cause?, subType?, preserveExistingClassification?, skipValidatorFields? }`. Auto-picks the first "done" transition when `transitionName` is omitted. When the chosen transition is done-category, the four SS workflow-validator fields (`components`, `Business Service`, `System`, `Cause`) are populated first (see "Closing SS tickets" below). Non-done transitions skip the resolution field (fixes issue #9). +- `POST /ticket/:key/confirmFixed` — CC-agent convenience. Body: `{ subType?, comment?, component?, businessService?, system?, cause?, internal? }`. Closes with `resolution=Done`. +- `POST /ticket/:key/customerCancelled` — CC-agent convenience. Body: `{ subType?, reason?, component?, businessService?, system?, cause?, internal? }`. Closes with `resolution=Won't Do`. +- `POST /ticket/:key/duplicate` — CC-agent convenience. Body: `{ primaryKey (required), subType?, comment?, component?, businessService?, system?, cause?, internal? }`. Creates a formal `Duplicate` issue link to `primaryKey`, then closes with `resolution=Duplicate`. ### Store Support (JSM requests) @@ -57,16 +60,28 @@ Base path: `/api/wxccai`. ### Admin -- `GET /admin/storesCache/status` — snapshot of the local Assets store cache: `{ storeCount, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }`. Safe for health checks. -- `POST /admin/storesCache/refresh` — force an immediate resync via the personal PAT (see below). Takes a few seconds and returns the new status. +- `GET /admin/caches` — snapshot of all four Assets object caches (stores, businessServices, systems, causes): `{ caches: [{ name, count, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }] }`. Safe for health checks. +- `GET /admin/caches/:name/status` — same shape, one cache. +- `POST /admin/caches/:name/refresh` — force an immediate resync of one cache via the personal PAT (see below). Takes a few seconds. +- `POST /admin/caches/refreshAll` — refresh every cache in parallel. +- `GET /admin/storesCache/status` and `POST /admin/storesCache/refresh` — backward-compat aliases for the stores cache endpoints above. ### Debug (non-production only) - `GET /debug/assetsProbe?storeNumber=305` — runs several AQL variants against Jira Assets and returns visible schemas + object-type detail + a computed diagnosis. Returns 404 when `NODE_ENV=production`. -## Stores cache (Assets workaround) +## Assets object caches (Assets workaround) -`createSSRequest` needs to translate a store number into an Assets object id. The shared service account is silently filtered out of the Store object type, so the app maintains a local `storeNumber → objectId` cache that's populated from a **personal Atlassian PAT** (a real human account with the right Assets role). The service account is still used for everything else (creating tickets, comments, attachments). +Several parts of the SS lifecycle need to translate a human-readable name (a store number, a Business Service name, a System name, a Cause code) into a Jira Assets **object id** before the value can be written to a CMDB custom field. The shared service account is silently filtered out of the underlying object schema (68), so the app maintains four local caches that are populated from a **personal Atlassian PAT** (a real human account with the right Assets role): + +| Cache | Object type | Used for | +| ------------------ | ----------- | ------------------------------------------------------------------- | +| `stores` | 109 | `customfield_10261 Store Number` on new SS tickets | +| `businessServices` | 100 | `customfield_10224 Business Service` workflow validator on close | +| `systems` | 103 | `customfield_10225 System` workflow validator on close | +| `causes` | 107 | `customfield_10233 Cause` workflow validator on close | + +All four are backed by the same shared factory (`services/jira/assetsObjectCache.js`) and use the same PAT credentials. The service account is still used for everything else (creating tickets, comments, attachments, transitions). Both storage patterns end up in the same place — `process.env.ASSETS_SYNC_TOKEN` — so the runtime code path is identical. Pick whichever fits the host. @@ -111,12 +126,56 @@ If `ASSETS_SYNC_TOKEN` is already in the process env (Setup A above), the wrappe ### Runtime behavior -On boot the app loads the on-disk cache at `data/stores.json`, kicks off a background refresh if the snapshot is missing or older than `STORES_CACHE_STALE_AFTER_HOURS`, and schedules a periodic full resync every `STORES_CACHE_REFRESH_HOURS`. `resolveStoreAssetReference` then serves lookups from memory (sub-ms) with a live PAT lookup as fallback for brand-new stores. +On boot the app loads each on-disk cache from `$CACHES_DIR/{name}.json` (default `./data/`), kicks off a background refresh for any snapshot missing or older than `CACHES_STALE_AFTER_HOURS`, and schedules a periodic full resync every `CACHES_REFRESH_HOURS`. `resolveStoreAssetReference` serves lookups from memory (sub-ms) with a live PAT lookup as fallback for brand-new stores. The three close-time caches (business services, systems, causes) are much smaller (dozens to a few hundred entries) and rarely change. Force a refresh at any time: ```bash -curl -X POST http://localhost:1866/api/wxccai/admin/storesCache/refresh +curl -X POST http://localhost:1866/api/wxccai/admin/caches/stores/refresh +curl -X POST http://localhost:1866/api/wxccai/admin/caches/refreshAll +``` + +## Closing SS tickets + +The `Resolved` transition on SS tickets fires a workflow validator that requires four fields to be populated: + +- `components` — Jira native (63 options in the SS project) +- `customfield_10224` Business Service — Assets CMDB +- `customfield_10225` System — Assets CMDB +- `customfield_10233` Cause — Assets CMDB (the value "Unknown" exists as a designed catch-all) + +`closeTicket` fills these in **before** calling the Resolved transition. Resolution order per field: + +1. Explicit value in the request body (`component`, `businessService`, `system`, `cause`) +2. Whatever is already on the ticket (if `preserveExistingClassification` is `true`, the default — respects human triage) +3. The per-subType default from `src/config/ssCloseDefaults.js` +4. The `__default__` entry (Help Desk / Store Technology / I can't find my option - Misc / Unknown) + +For CC-agent-driven closes, the shortest path is to send just `subType` and `comment`; everything else is defaulted. Use the convenience routes: + +```bash +# Caller confirms the issue is resolved +curl -X POST http://localhost:1866/api/wxccai/ticket/SS-20948/confirmFixed \ + -H 'Content-Type: application/json' \ + -d '{"subType":"Report a Technology issue"}' + +# Caller wants to cancel +curl -X POST http://localhost:1866/api/wxccai/ticket/SS-20949/customerCancelled \ + -H 'Content-Type: application/json' \ + -d '{"subType":"Broken device / hardware","reason":"changed their mind"}' + +# Duplicate of an earlier ticket (creates a formal Duplicate issueLink) +curl -X POST http://localhost:1866/api/wxccai/ticket/SS-20950/duplicate \ + -H 'Content-Type: application/json' \ + -d '{"primaryKey":"SS-20948"}' +``` + +To override the auto-detected classification (for a subType not in the defaults map, or when the caller volunteers specific context), pass any of the four fields explicitly. CMDB names are matched case-insensitively; you can also pass a raw Assets objectId as a shortcut for `businessService` / `system` / `cause`: + +```bash +curl -X POST http://localhost:1866/api/wxccai/ticket/SS-XXXXX/confirmFixed \ + -H 'Content-Type: application/json' \ + -d '{"subType":"Broken device / hardware","system":"Printer","cause":"Broken Equipment"}' ``` ## Jira Assets gotcha diff --git a/src/app.js b/src/app.js index 21a9ecd..1fa98bb 100644 --- a/src/app.js +++ b/src/app.js @@ -2,7 +2,7 @@ import express from 'express'; import config from './config/index.js'; import wxccRoutes from './routes/wxccRoutes.js'; import { getDetailedHealth } from './services/healthService.js'; -import * as storesCache from './services/jira/storesCache.js'; +import { allCaches } from './services/jira/caches.js'; import logger from './utilities/logger.js'; const app = express(); @@ -135,9 +135,13 @@ app.use((req, res) => { app.listen(config.port, () => { logger.info(`Server running on port ${config.port} in ${config.nodeEnv} mode`); - // Kick off the stores cache: load from disk, schedule periodic refresh, - // and (if stale) start a background full refresh. Never blocks startup. - storesCache.init().catch(err => { - logger.error('storesCache.init failed at boot', { error: err.message }); - }); + // Kick off all Assets object caches: load from disk, schedule periodic + // refresh, and (if stale) start a background full refresh. Never blocks + // startup. Each cache runs independently — failure of one doesn't stop + // the others. + for (const cache of allCaches) { + cache.init().catch(err => { + logger.error(`cache.init failed at boot`, { cache: cache.name, error: err.message }); + }); + } }); \ No newline at end of file diff --git a/src/config/index.js b/src/config/index.js index 509ae60..7ed42f2 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -86,18 +86,21 @@ export const config = { assetsSyncToken: process.env.ASSETS_SYNC_TOKEN || null, }, - // Local cache of the Assets Store schema. Backed by a JSON file on disk; - // refreshed periodically via the personal PAT above. - storesCache: { - enabled: boolFromEnv('STORES_CACHE_ENABLED', true), - // Where the on-disk snapshot lives. Default is data/stores.json under - // the process cwd; gitignored. - path: process.env.STORES_CACHE_PATH?.trim() - || path.resolve(process.cwd(), 'data', 'stores.json'), - refreshIntervalHours: intFromEnv('STORES_CACHE_REFRESH_HOURS', 24), - staleAfterHours: intFromEnv('STORES_CACHE_STALE_AFTER_HOURS', 48), - pageSize: intFromEnv('STORES_CACHE_PAGE_SIZE', 500), - maxPages: intFromEnv('STORES_CACHE_MAX_PAGES', 200), + // Shared config for all Assets object caches (stores, business services, + // systems, causes). Each cache is a `Map` backed by a JSON + // file on disk at `${dir}/${cacheName}.json`. All caches use the same + // personal PAT auth path (see jira.assetsSyncToken above). + caches: { + enabled: boolFromEnv('CACHES_ENABLED', true), + // Directory where on-disk snapshots live. Filenames are auto-derived + // per cache: stores.json, businessServices.json, systems.json, + // causes.json. Gitignored via data/ in .gitignore. + dir: process.env.CACHES_DIR?.trim() + || path.resolve(process.cwd(), 'data'), + refreshIntervalHours: intFromEnv('CACHES_REFRESH_HOURS', 24), + staleAfterHours: intFromEnv('CACHES_STALE_AFTER_HOURS', 48), + pageSize: intFromEnv('CACHES_PAGE_SIZE', 500), + maxPages: intFromEnv('CACHES_MAX_PAGES', 200), }, xai: { diff --git a/src/config/ssCloseDefaults.js b/src/config/ssCloseDefaults.js new file mode 100644 index 0000000..d7dbfd9 --- /dev/null +++ b/src/config/ssCloseDefaults.js @@ -0,0 +1,142 @@ +// Per-subType default classifications for closing SS tickets. +// +// The Resolved (161) transition has a workflow validator that requires four +// fields to be set on the ticket before it can complete: +// - components (Jira native, 63 options) +// - Business Service (CMDB customfield_10224) +// - System (CMDB customfield_10225) +// - Cause (CMDB customfield_10233) +// +// Only the values that ACTUALLY exist in the tenant (as verified against the +// live caches on 2026-07-07) are used here — otherwise the close will fail +// with the "please select relevant …" workflow validator error. +// +// Resolution order used by closeTicket (see issues.js closeTicket): +// 1. Caller-supplied value in the request body +// 2. Whatever's already on the ticket (respects human triage) +// 3. The subType-specific default from this file (below) +// 4. The __default__ entry, if the subType isn't listed +// 5. Fail with a clear error if step 4 also has an empty slot +// +// All values are matched case-insensitively via the caches' normalizeKey. + +export const SS_CLOSE_DEFAULTS = { + // Fallback for any subType not listed below. Uses the tenant's designed + // "I don't know" escape valves ("Unknown" cause, "I can't find my option + // - Misc" system) plus Help Desk as the safest component (that queue + // does further triage). + __default__: { + component: 'Help Desk', + businessService: 'Store Technology', + system: "I can't find my option - Misc", + cause: 'Unknown', + }, + + // ----- Point of Sale / Register ----- + 'Register Not functioning properly': { + component: 'Store Platform', + businessService: 'Fixed Register', + system: 'Oracle Point of Sale (POS)', + cause: 'Unknown', + }, + 'Unable to login': { + component: 'Identity Platform', + businessService: 'Store Technology', + system: 'Login', + cause: 'Access Expired', + }, + + // ----- Business reporting ----- + 'Business report issue': { + component: 'Store Operations Technology', + businessService: 'Business Reporting', + system: 'Reporting', + cause: 'Unknown', + }, + 'Business Report Issue': { + component: 'Store Operations Technology', + businessService: 'Business Reporting', + system: 'Reporting', + cause: 'Unknown', + }, + + // ----- Hardware ----- + 'Broken device / hardware': { + component: 'Store Technology Experience', + businessService: 'Store Technology', + system: 'Peripheral Devices', + cause: 'Broken Equipment', + }, + 'Broken Device / Hardware': { + component: 'Store Technology Experience', + businessService: 'Store Technology', + system: 'Peripheral Devices', + cause: 'Broken Equipment', + }, + 'Report Missing Hardware': { + component: 'Store Technology Experience', + businessService: 'Store Technology', + system: 'Peripheral Devices', + cause: 'Missing/Stolen Equipment', + }, + 'Request Additional Hardware': { + component: 'Store Technology Experience', + businessService: 'Store Technology', + system: 'Peripheral Devices', + cause: 'New Request', + }, + + // ----- Technology (misc) ----- + 'Report a Technology issue': { + component: 'Help Desk', + businessService: 'Store Technology', + system: "I can't find my option - Misc", + cause: 'Unknown', + }, + 'Report an Issue with Sterling Application': { + component: 'Sterling Ops', + businessService: 'Store Technology', + system: 'Sterling Application', + cause: 'Unknown', + }, + 'Omni Turn Off / On': { + component: 'Omni Operations', + businessService: 'Store Technology', + system: 'On/Off Request', + cause: 'New Request', + }, + 'Report a Traffic Counter Issue': { + component: 'Store Technology Experience', + businessService: 'Store Technology', + system: 'RetailNext Traffic', + cause: 'Unknown', + }, + + // ----- UKG / Workforce Management ----- + // Verified from real closed tickets SS-20272, SS-20022, SS-19863 which + // all used exactly this shape. + 'UKG Pro / Workforce Management Issues': { + component: 'UKG_COE', + businessService: 'Store Technology', + system: 'UKG Pro WFM', + cause: 'Unknown', + }, + + // ----- Store Transportation ----- + 'Store Transportation Request': { + component: 'Transportation', + businessService: 'Store Technology', + system: 'Transportation', + cause: 'New Request', + }, +}; + +/** + * Get the defaults for a subType, falling back to the __default__ entry. + * Returns a plain object; callers may safely mutate the returned object. + */ +export function getDefaultsForSubType(subType) { + const specific = SS_CLOSE_DEFAULTS[subType]; + if (specific) return { ...specific }; + return { ...SS_CLOSE_DEFAULTS.__default__ }; +} diff --git a/src/routes/wxccRoutes.js b/src/routes/wxccRoutes.js index a7aa0e4..94a5740 100644 --- a/src/routes/wxccRoutes.js +++ b/src/routes/wxccRoutes.js @@ -288,20 +288,41 @@ router.get('/wxccai/ticket/:key/transitions', async (req, res) => { } }); -// Body: { transitionName?, resolution?, comment?, internal? } -// If transitionName is omitted, we auto-pick the first "done" transition. +// Body: { +// transitionName?, // explicit override; else auto-pick a "done" transition +// resolution?, // "Done" | "Won't Do" | "Duplicate" | "Fixed" | ... (only used for done-category transitions) +// comment?, // audit-trail comment +// internal?, // comment visibility +// component?, // Jira component name (workflow validator) +// businessService?, // CMDB name or objectId (workflow validator) +// system?, // CMDB name or objectId (workflow validator) +// cause?, // CMDB name or objectId (workflow validator; usually "Unknown") +// subType?, // SS request subType; used to look up defaults from ssCloseDefaults.js +// preserveExistingClassification?, // default true; if false, overwrites anything already on the ticket +// skipValidatorFields? // if true, skip the pre-transition field write +// } router.post('/wxccai/ticket/:key/close', async (req, res) => { const key = req.params.key?.trim().toUpperCase(); if (!key || !KEY_RE.test(key)) { return res.status(400).json({ error: 'Invalid Jira key' }); } - const { transitionName, resolution, comment, internal } = req.body || {}; + const body = req.body || {}; try { const result = await jiraService.closeTicket(key, { - transitionName, - resolution: resolution || 'Done', - comment, - internal: !!internal + transitionName: body.transitionName, + // Deliberately NOT defaulting to 'Done' here anymore — closeTicket() + // will only send a resolution when the chosen transition is + // done-category (fixes bug #9). + resolution: body.resolution || undefined, + comment: body.comment, + internal: !!body.internal, + component: body.component, + businessService: body.businessService, + system: body.system, + cause: body.cause, + subType: body.subType, + preserveExistingClassification: body.preserveExistingClassification !== false, + skipValidatorFields: !!body.skipValidatorFields, }); res.json({ success: true, ...result }); } catch (err) { @@ -312,40 +333,152 @@ router.post('/wxccai/ticket/:key/close', async (req, res) => { } }); -// ======================== -// Stores cache admin -// GET /api/wxccai/admin/storesCache/status — snapshot of cache state -// POST /api/wxccai/admin/storesCache/refresh — force an immediate resync +// Contact Center convenience wrappers over /close. Each takes a small body +// with just the intent-specific fields; the rest is defaulted from subType. // -// These are admin endpoints (not user-facing). The status endpoint is safe to -// hit from a health check; the refresh endpoint triggers an AQL walk of the -// entire Store object type via the personal PAT and takes a few seconds on a -// warm connection. Refresh is a no-op if credentials aren't configured or a -// sync is already in progress. -// ======================== -router.get('/wxccai/admin/storesCache/status', (req, res) => { +// Body: { comment?, subType?, component?, businessService?, system?, cause?, internal? } +router.post('/wxccai/ticket/:key/confirmFixed', async (req, res) => { + const key = req.params.key?.trim().toUpperCase(); + if (!key || !KEY_RE.test(key)) { + return res.status(400).json({ error: 'Invalid Jira key' }); + } try { - res.json(jiraService.storesCache.status()); + const result = await jiraService.confirmFixed(key, req.body || {}); + res.json({ success: true, ...result }); } catch (err) { - logger.error('storesCache status failed', { error: err.message }); + logger.error('confirmFixed route failed', { key, error: err.message, details: err.details }); + res.status(err.status && err.status < 500 ? err.status : 500).json({ + success: false, error: err.message, details: err.details || null + }); + } +}); + +// Body: { reason?, subType?, component?, businessService?, system?, cause?, internal? } +router.post('/wxccai/ticket/:key/customerCancelled', async (req, res) => { + const key = req.params.key?.trim().toUpperCase(); + if (!key || !KEY_RE.test(key)) { + return res.status(400).json({ error: 'Invalid Jira key' }); + } + try { + const result = await jiraService.customerCancelled(key, req.body || {}); + res.json({ success: true, ...result }); + } catch (err) { + logger.error('customerCancelled route failed', { key, error: err.message, details: err.details }); + res.status(err.status && err.status < 500 ? err.status : 500).json({ + success: false, error: err.message, details: err.details || null + }); + } +}); + +// Body: { primaryKey!, comment?, subType?, component?, businessService?, system?, cause?, internal? } +router.post('/wxccai/ticket/:key/duplicate', async (req, res) => { + const key = req.params.key?.trim().toUpperCase(); + if (!key || !KEY_RE.test(key)) { + return res.status(400).json({ error: 'Invalid Jira key' }); + } + const body = req.body || {}; + if (!body.primaryKey) { + return res.status(400).json({ error: 'primaryKey is required in the body' }); + } + try { + const result = await jiraService.markDuplicate(key, { + ...body, + primaryKey: String(body.primaryKey).trim().toUpperCase(), + }); + res.json({ success: true, ...result }); + } catch (err) { + logger.error('duplicate route failed', { key, error: err.message, details: err.details }); + res.status(err.status && err.status < 500 ? err.status : 500).json({ + success: false, error: err.message, details: err.details || null + }); + } +}); + +// ======================== +// Assets object cache admin +// GET /api/wxccai/admin/caches — status of every cache +// GET /api/wxccai/admin/caches/:name/status — status of one cache +// POST /api/wxccai/admin/caches/:name/refresh — force resync of one cache +// POST /api/wxccai/admin/caches/refreshAll — resync every cache in parallel +// +// Cache names: stores | businessServices | systems | causes. +// +// Also kept as backward-compat aliases: +// GET /api/wxccai/admin/storesCache/status -> caches/stores/status +// POST /api/wxccai/admin/storesCache/refresh -> caches/stores/refresh +// +// These are admin endpoints (not user-facing). Status is safe from a health +// check; refresh triggers a full AQL walk via the personal PAT and takes a +// few seconds. Refresh is a no-op if creds aren't configured or a sync for +// that cache is already in progress. +// ======================== +import { allCaches, cachesByName } from '../services/jira/caches.js'; + +router.get('/wxccai/admin/caches', (req, res) => { + try { + res.json({ caches: allCaches.map(c => c.status()) }); + } catch (err) { + logger.error('caches status failed', { error: err.message }); res.status(500).json({ error: err.message }); } }); -router.post('/wxccai/admin/storesCache/refresh', async (req, res) => { +router.get('/wxccai/admin/caches/:name/status', (req, res) => { + const c = cachesByName[req.params.name]; + if (!c) return res.status(404).json({ error: `unknown cache '${req.params.name}'`, available: Object.keys(cachesByName) }); try { - const result = await jiraService.storesCache.refresh({ force: true }); - res.json({ success: true, ...result, status: jiraService.storesCache.status() }); + res.json(c.status()); } catch (err) { - logger.error('storesCache refresh failed', { error: err.message }); + logger.error('cache status failed', { name: req.params.name, error: err.message }); + res.status(500).json({ error: err.message }); + } +}); + +router.post('/wxccai/admin/caches/:name/refresh', async (req, res) => { + const c = cachesByName[req.params.name]; + if (!c) return res.status(404).json({ error: `unknown cache '${req.params.name}'`, available: Object.keys(cachesByName) }); + try { + const result = await c.refresh({ force: true }); + res.json({ success: true, ...result, status: c.status() }); + } catch (err) { + logger.error('cache refresh failed', { name: req.params.name, error: err.message }); res.status(500).json({ success: false, error: err.message, - status: jiraService.storesCache.status(), + status: c.status(), }); } }); +router.post('/wxccai/admin/caches/refreshAll', async (req, res) => { + const results = await Promise.allSettled(allCaches.map(c => c.refresh({ force: true }))); + res.json({ + results: results.map((r, i) => ({ + cache: allCaches[i].name, + ok: r.status === 'fulfilled', + ...(r.status === 'fulfilled' ? r.value : { error: r.reason?.message }), + })), + statuses: allCaches.map(c => c.status()), + }); +}); + +// Backward-compat aliases (old storesCache path) +router.get('/wxccai/admin/storesCache/status', (req, res) => { + try { + res.json(cachesByName.stores.status()); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); +router.post('/wxccai/admin/storesCache/refresh', async (req, res) => { + try { + const result = await cachesByName.stores.refresh({ force: true }); + res.json({ success: true, ...result, status: cachesByName.stores.status() }); + } catch (err) { + res.status(500).json({ success: false, error: err.message, status: cachesByName.stores.status() }); + } +}); + // ======================== // Assets diagnostic (non-production only) // GET /api/wxccai/debug/assetsProbe?storeNumber=305 diff --git a/src/services/jira/assetsObjectCache.js b/src/services/jira/assetsObjectCache.js new file mode 100644 index 0000000..5ed67f4 --- /dev/null +++ b/src/services/jira/assetsObjectCache.js @@ -0,0 +1,309 @@ +// Factory for building in-memory + on-disk caches of Jira Assets objects. +// +// All caches share the same shape: +// - In-memory Map +// - On-disk JSON at `${config.caches.dir}/${name}.json` (atomic write) +// - Populated via the personal-PAT `assetsSyncClient` (never the service +// account, so the same permission workaround for issue #1 applies) +// +// Callers get back an object with the same API as the old storesCache module: +// { name, displayName, get, status, refresh, init, shutdown } +// +// Concrete instances live in stores/businessServices/systems/causes cache +// modules and just call `createAssetsObjectCache({...})` with the right +// objectTypeId + key extraction function. See docs on those modules for the +// tenant-specific object types. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import config from '../../config/index.js'; +import logger from '../../utilities/logger.js'; +import { assetsAql, isAssetsSyncConfigured, describeSyncIdentity } from './assetsSyncClient.js'; + +/** + * Build a new Assets object cache instance. + * + * @param {Object} opts + * @param {string} opts.name Short slug: 'stores' | 'businessServices' | 'systems' | 'causes'. + * Used for filename, log tags, admin route param. + * @param {string} opts.displayName Human-readable name for log messages ('Stores', 'Business Services'). + * @param {string} opts.objectTypeId Assets object type to enumerate (e.g. '109'). + * @param {Function} opts.keyFromObject (obj) => string | null. Derives the canonical cache key + * from an Assets object entry. Return null to skip the object + * (it'll count as "orphaned" in the refresh stats). + * @param {Function} opts.normalizeKey (userInput) => string | null. Normalizes a caller-supplied + * key for lookup. Must produce the same output as keyFromObject + * for equivalent inputs. + * @param {string} [opts.filePath] Override the on-disk cache path. Defaults to + * `${config.caches.dir}/${name}.json`. + * @returns {Object} cache instance + */ +export function createAssetsObjectCache({ + name, + displayName, + objectTypeId, + keyFromObject, + normalizeKey, + filePath, +}) { + if (!name || !objectTypeId || typeof keyFromObject !== 'function' || typeof normalizeKey !== 'function') { + throw new Error(`createAssetsObjectCache: name, objectTypeId, keyFromObject, normalizeKey are required (got name=${name})`); + } + + const displayNameFinal = displayName || name; + const cachePath = () => filePath || path.join(config.caches.dir, `${name}.json`); + const tag = `[cache:${name}]`; + + const state = { + cache: new Map(), + lastSyncAt: null, + lastError: null, + syncing: false, + scheduleTimer: null, + loadedFromDisk: false, + }; + + function get(userKey) { + const k = normalizeKey(userKey); + if (!k) return null; + return state.cache.get(k) || null; + } + + function status() { + const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null; + return { + name, + displayName: displayNameFinal, + objectTypeId, + count: state.cache.size, + lastSyncAt: state.lastSyncAt, + ageSeconds: lastSyncEpoch ? Math.floor((Date.now() - lastSyncEpoch) / 1000) : null, + syncing: state.syncing, + lastError: state.lastError, + loadedFromDisk: state.loadedFromDisk, + assetsSyncConfigured: isAssetsSyncConfigured(), + syncIdentity: describeSyncIdentity(), + cachePath: cachePath(), + refreshIntervalHours: config.caches.refreshIntervalHours, + staleAfterHours: config.caches.staleAfterHours, + }; + } + + async function loadFromDisk() { + const p = cachePath(); + try { + const raw = await fs.readFile(p, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && parsed.entries) { + state.cache = new Map(Object.entries(parsed.entries)); + state.lastSyncAt = parsed.lastSyncAt || null; + state.loadedFromDisk = true; + logger.info(`${tag} loaded from disk`, { + path: p, + count: state.cache.size, + lastSyncAt: state.lastSyncAt, + }); + return true; + } + logger.warn(`${tag} disk file present but shape unexpected; ignoring`, { path: p }); + } catch (e) { + if (e.code === 'ENOENT') { + logger.info(`${tag} no on-disk snapshot yet; will populate on first sync`, { path: p }); + } else { + logger.warn(`${tag} failed to load from disk`, { path: p, error: e.message }); + } + } + return false; + } + + async function saveToDisk() { + const p = cachePath(); + const dir = path.dirname(p); + try { + await fs.mkdir(dir, { recursive: true }); + const payload = { + version: 1, + cacheName: name, + objectTypeId, + lastSyncAt: state.lastSyncAt, + count: state.cache.size, + entries: Object.fromEntries(state.cache), + }; + const tmp = `${p}.tmp`; + await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8'); + await fs.rename(tmp, p); + logger.debug(`${tag} persisted to disk`, { path: p, count: state.cache.size }); + } catch (e) { + logger.warn(`${tag} failed to persist to disk`, { path: p, error: e.message }); + } + } + + /** + * Full paginated resync via personal PAT. Rebuilds the in-memory Map + * atomically (only swaps once every page succeeded). Never reentrant. + */ + async function refresh({ force = false } = {}) { + if (state.syncing) { + logger.info(`${tag} refresh already in progress; ignoring duplicate call`); + return { skipped: true, reason: 'already_syncing' }; + } + if (!isAssetsSyncConfigured()) { + return { skipped: true, reason: 'not_configured' }; + } + + state.syncing = true; + state.lastError = null; + const started = Date.now(); + const pageSize = config.caches.pageSize || 500; + const maxPages = config.caches.maxPages || 200; + + try { + const newCache = new Map(); + let startAt = 0; + let page = 0; + let seenTotal = 0; + let expectedTotal = null; + let orphaned = 0; + + while (page < maxPages) { + const r = await assetsAql(`objectTypeId = ${objectTypeId}`, { + startAt, + maxResults: pageSize, + includeAttributes: true, + }); + + if (r.status !== 200) { + const bodySnippet = typeof r.data === 'object' + ? JSON.stringify(r.data).slice(0, 400) + : String(r.data).slice(0, 400); + throw new Error( + `${tag} AQL page ${page + 1} (startAt=${startAt}) returned HTTP ${r.status} (${r.statusText}). Body: ${bodySnippet}` + ); + } + + const values = Array.isArray(r.data?.values) ? r.data.values : []; + const returnedMaxResults = typeof r.data?.maxResults === 'number' ? r.data.maxResults : pageSize; + if (expectedTotal === null && typeof r.data?.total === 'number') { + expectedTotal = r.data.total; + } + + for (const obj of values) { + const key = keyFromObject(obj); + if (!key || !obj?.id) { + orphaned++; + continue; + } + newCache.set(key, { + objectId: String(obj.id), + objectKey: obj.objectKey || null, + label: obj.label || obj.name || null, + syncedAt: new Date().toISOString(), + }); + } + seenTotal += values.length; + page++; + + const isLast = r.data?.isLast === true + || values.length === 0 + || values.length < returnedMaxResults; + + logger.debug(`${tag} sync page`, { + page, + startAt, + returnedMaxResults, + rowsThisPage: values.length, + seenTotal, + expectedTotal, + mappedSoFar: newCache.size, + orphanedSoFar: orphaned, + isLast, + }); + + if (isLast) break; + startAt += values.length; + } + + if (page >= maxPages) { + logger.warn(`${tag} sync hit page cap; some objects may be missing`, { + maxPages, + pageSize, + seenTotal, + }); + } + + state.cache = newCache; + state.lastSyncAt = new Date().toISOString(); + await saveToDisk(); + + const durationMs = Date.now() - started; + logger.info(`${tag} refresh complete`, { + count: state.cache.size, + orphaned, + pagesRead: page, + durationMs, + forced: force, + }); + + return { + ok: true, + cache: name, + count: state.cache.size, + orphaned, + pagesRead: page, + durationMs, + }; + } catch (e) { + state.lastError = { message: e.message, at: new Date().toISOString() }; + logger.error(`${tag} refresh failed`, { error: e.message }); + throw e; + } finally { + state.syncing = false; + } + } + + /** + * Load the disk snapshot, kick off a background refresh if + * missing/stale, schedule periodic refresh. Idempotent. + */ + async function init() { + if (!config.caches.enabled) { + logger.info(`${tag} disabled via config.caches.enabled; skipping init`); + return; + } + + await loadFromDisk(); + + const staleAfterMs = (config.caches.staleAfterHours || 48) * 3600 * 1000; + const shouldRefreshNow = !state.lastSyncAt + || (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs; + + if (shouldRefreshNow && isAssetsSyncConfigured()) { + logger.info(`${tag} missing or stale; starting background refresh`); + refresh().catch(e => logger.error(`${tag} initial refresh failed`, { error: e.message })); + } else if (shouldRefreshNow) { + logger.warn(`${tag} missing or stale but assets sync not configured; skipping initial refresh`); + } + + const intervalHours = config.caches.refreshIntervalHours; + if (intervalHours > 0) { + if (state.scheduleTimer) clearInterval(state.scheduleTimer); + state.scheduleTimer = setInterval(() => { + if (!isAssetsSyncConfigured()) return; + refresh().catch(e => logger.error(`${tag} scheduled refresh failed`, { error: e.message })); + }, intervalHours * 3600 * 1000); + state.scheduleTimer.unref?.(); + logger.info(`${tag} periodic refresh scheduled`, { intervalHours }); + } + } + + function shutdown() { + if (state.scheduleTimer) clearInterval(state.scheduleTimer); + state.scheduleTimer = null; + state.cache = new Map(); + state.lastSyncAt = null; + state.lastError = null; + state.syncing = false; + state.loadedFromDisk = false; + } + + return { name, displayName: displayNameFinal, objectTypeId, get, status, refresh, init, shutdown }; +} diff --git a/src/services/jira/businessServicesCache.js b/src/services/jira/businessServicesCache.js new file mode 100644 index 0000000..9716872 --- /dev/null +++ b/src/services/jira/businessServicesCache.js @@ -0,0 +1,32 @@ +// Business Service cache — Assets ObjectType 100 in schema 68. +// +// Backs the `customfield_10224 Business Service` validator that fires on the +// Resolved transition of SS tickets. Populated via the personal PAT sync. +// +// Examples of real values observed on closed SS tickets: "Store Technology". +// The set is expected to be small (dozens, not thousands) so pagination is +// usually one page. +import { createAssetsObjectCache } from './assetsObjectCache.js'; + +function normalizeName(raw) { + if (raw === null || raw === undefined) return null; + const s = String(raw).trim().toLowerCase(); + return s || null; +} + +function keyFromObject(obj) { + const label = obj?.label || obj?.name; + if (!label) return null; + return String(label).trim().toLowerCase(); +} + +const instance = createAssetsObjectCache({ + name: 'businessServices', + displayName: 'Business Services', + objectTypeId: '100', + keyFromObject, + normalizeKey: normalizeName, +}); + +export const { get, status, refresh, init, shutdown } = instance; +export default instance; diff --git a/src/services/jira/caches.js b/src/services/jira/caches.js new file mode 100644 index 0000000..0853a91 --- /dev/null +++ b/src/services/jira/caches.js @@ -0,0 +1,23 @@ +// Barrel export for all Assets object cache instances. +// +// Every cache is a call to createAssetsObjectCache(...) with tenant-specific +// object types and key extractors. Consumers use one of: +// - the named module import (storesCache, businessServicesCache, ...) for +// direct .get() lookups on the hot path +// - `allCaches` for lifecycle operations (init on boot, iterate for admin) +// - `cachesByName` for admin routes that take a cache name as a param +import storesCache from './storesCache.js'; +import businessServicesCache from './businessServicesCache.js'; +import systemsCache from './systemsCache.js'; +import causesCache from './causesCache.js'; + +export { storesCache, businessServicesCache, systemsCache, causesCache }; + +export const allCaches = [ + storesCache, + businessServicesCache, + systemsCache, + causesCache, +]; + +export const cachesByName = Object.fromEntries(allCaches.map(c => [c.name, c])); diff --git a/src/services/jira/causesCache.js b/src/services/jira/causesCache.js new file mode 100644 index 0000000..0b69814 --- /dev/null +++ b/src/services/jira/causesCache.js @@ -0,0 +1,32 @@ +// Causes cache — Assets ObjectType 107 "Cause Code" in schema 68. +// +// Backs the `customfield_10233 Cause` validator that fires on the Resolved +// transition of SS tickets. Populated via the personal PAT sync. +// +// Real closed tickets consistently show "Unknown" (objectId 83132) here, +// which is the workflow's designed catch-all value for cases where the root +// cause can't be pinned down. Perfect fallback for AI-driven closures. +import { createAssetsObjectCache } from './assetsObjectCache.js'; + +function normalizeName(raw) { + if (raw === null || raw === undefined) return null; + const s = String(raw).trim().toLowerCase(); + return s || null; +} + +function keyFromObject(obj) { + const label = obj?.label || obj?.name; + if (!label) return null; + return String(label).trim().toLowerCase(); +} + +const instance = createAssetsObjectCache({ + name: 'causes', + displayName: 'Causes', + objectTypeId: '107', + keyFromObject, + normalizeKey: normalizeName, +}); + +export const { get, status, refresh, init, shutdown } = instance; +export default instance; diff --git a/src/services/jira/closeHelpers.js b/src/services/jira/closeHelpers.js new file mode 100644 index 0000000..b7aae22 --- /dev/null +++ b/src/services/jira/closeHelpers.js @@ -0,0 +1,163 @@ +// Convenience wrappers around closeTicket() aimed at Contact Center agent +// intents. Each one calls closeTicket with a specific resolution + a +// standardized audit-trail comment, plus any intent-specific side effects +// (like creating a formal "Duplicate" issueLink). +// +// These are thin — the real work lives in issues.js closeTicket + +// setSSValidatorFields. If a caller wants full control, they can still hit +// /ticket/:key/close directly. +import logger from '../../utilities/logger.js'; +import { jiraClient } from './client.js'; +import { closeTicket } from './issues.js'; + +const KEY_RE = /^[A-Z]+-\d+$/; + +/** + * "The caller confirms it's fixed." Resolves the ticket as Done. + * + * @param {string} key + * @param {Object} [opts] + * @param {string} [opts.comment] Extra context to append; a default + * is used if omitted. + * @param {string} [opts.subType] Feeds the validator-field default + * lookup in ssCloseDefaults.js. + * @param {string} [opts.component] Override the default component. + * @param {string} [opts.businessService] + * @param {string} [opts.system] + * @param {string} [opts.cause] Defaults to "Unknown" for CC-driven + * closes, which is the tenant's designed + * catch-all Cause Code. + * @param {boolean} [opts.internal=true] Post comment as internal (visible only + * to Service Desk Team). Default true + * because CC-agent notes should not + * show to the customer as public replies. + */ +export async function confirmFixed(key, opts = {}) { + if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`); + const { + comment, + subType, + component, businessService, system, cause, + internal = true, + } = opts; + + const finalComment = comment + || 'Closed via WxCC AI agent: caller confirmed the issue is resolved.'; + + return closeTicket(key, { + resolution: 'Done', + comment: finalComment, + internal, + component, businessService, system, cause, + subType, + }); +} + +/** + * "The caller wants to cancel their ticket." Resolves as Won't Do. + * + * @param {string} key + * @param {Object} [opts] + * @param {string} [opts.reason] Freeform reason to include in the + * comment (e.g. "customer says the + * issue self-resolved"). + * @param {string} [opts.subType] Feeds default lookup. + * @param {string} [opts.component] + * @param {string} [opts.businessService] + * @param {string} [opts.system] + * @param {string} [opts.cause] Defaults to whatever the subType map + * says (usually "Unknown"). + * @param {boolean} [opts.internal=true] + */ +export async function customerCancelled(key, opts = {}) { + if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`); + const { + reason, + subType, + component, businessService, system, cause, + internal = true, + } = opts; + + const comment = reason + ? `Closed via WxCC AI agent: caller requested cancellation. Reason: ${reason}` + : 'Closed via WxCC AI agent: caller requested cancellation.'; + + return closeTicket(key, { + resolution: "Won't Do", + comment, + internal, + component, businessService, system, cause, + subType, + }); +} + +/** + * "This ticket is a duplicate of SS-XXXX." + * + * Closes the given ticket with resolution=Duplicate AND creates a formal + * `Duplicate` issue link pointing to the primary ticket. Both operations + * are attempted; if the link creation fails, the close still completes + * (the link error is logged and surfaced in the response). + * + * @param {string} key + * @param {Object} opts + * @param {string} opts.primaryKey The ticket this is a duplicate OF (required). + * @param {string} [opts.comment] Extra context; a default is used if omitted. + * @param {string} [opts.subType] + * @param {string} [opts.component] + * @param {string} [opts.businessService] + * @param {string} [opts.system] + * @param {string} [opts.cause] + * @param {boolean} [opts.internal=true] + */ +export async function markDuplicate(key, opts = {}) { + if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`); + const { + primaryKey, + comment, + subType, + component, businessService, system, cause, + internal = true, + } = opts; + + if (!primaryKey || !KEY_RE.test(primaryKey)) { + throw new Error(`markDuplicate requires primaryKey; got "${primaryKey}"`); + } + if (primaryKey === key) { + throw new Error(`markDuplicate: primaryKey (${primaryKey}) cannot equal the ticket being closed (${key})`); + } + + const finalComment = comment + || `Closed via WxCC AI agent: duplicate of ${primaryKey}.`; + + // Try to create the formal Duplicate link first. If Jira rejects it (e.g. + // primary doesn't exist), abort — closing without the link would lose + // the connection. + let linkResult = null; + try { + await jiraClient.post('/rest/api/3/issueLink', { + type: { name: 'Duplicate' }, + inwardIssue: { key: key }, + outwardIssue: { key: primaryKey }, + }, { headers: { 'Content-Type': 'application/json' } }); + linkResult = { ok: true, primaryKey, linkType: 'Duplicate' }; + logger.info('markDuplicate: created formal issueLink', { key, primaryKey }); + } catch (err) { + const detail = err.response?.data?.errorMessages?.join('; ') || err.message; + logger.warn('markDuplicate: issueLink create failed; will still close', { + key, primaryKey, error: detail, status: err.response?.status, + }); + linkResult = { ok: false, primaryKey, error: detail }; + // We continue: the close-with-comment still records the intent. + } + + const closeResult = await closeTicket(key, { + resolution: 'Duplicate', + comment: finalComment, + internal, + component, businessService, system, cause, + subType, + }); + + return { ...closeResult, issueLink: linkResult }; +} diff --git a/src/services/jira/issues.js b/src/services/jira/issues.js index 4ce4b4c..15bf16b 100644 --- a/src/services/jira/issues.js +++ b/src/services/jira/issues.js @@ -5,6 +5,8 @@ import logger from '../../utilities/logger.js'; import config from '../../config/index.js'; import { jiraClient, plainTextToAdf } from './client.js'; import { fetchPublicComments } from './comments.js'; +import { businessServicesCache, systemsCache, causesCache } from './caches.js'; +import { getDefaultsForSubType } from '../../config/ssCloseDefaults.js'; const KEY_RE = /^[A-Z]+-\d+$/; @@ -261,12 +263,244 @@ export async function transitionTicket(key, transitionId, { resolution, comment, } /** - * Convenience: find a "closing" transition and execute it. - * Prefers explicit `transitionName` if provided, otherwise picks the first - * transition whose target status is in category "done" (Jira's canonical - * category for closed/resolved/completed), falling back to a name-based match. + * Build the Cloud CMDB request-field value shape for a single object: + * [{ id: ":" }] + * Matches buildStoreFieldRef in assets.js. See that function's comment for + * why this is needed and how DC/Server used a different shape. */ -export async function closeTicket(key, { transitionName, resolution = 'Done', comment, internal = false } = {}) { +function cmdbFieldRef(objectId) { + const workspaceId = config.jira.assetsWorkspaceId; + if (!workspaceId) { + logger.error('cmdbFieldRef: JIRA_ASSETS_WORKSPACE_ID missing; CMDB field writes will silently fail on Jira Cloud'); + return [{ objectId: String(objectId) }]; + } + return [{ id: `${workspaceId}:${objectId}` }]; +} + +/** + * Resolve a caller-supplied CMDB field value. + * - If the value looks like an Assets objectId (all digits), use directly. + * - Otherwise look it up in the given cache by name. + * Returns objectId or null if not resolvable. + */ +function resolveCmdbNameOrId(nameOrId, cache) { + if (nameOrId === null || nameOrId === undefined) return null; + const s = String(nameOrId).trim(); + if (!s) return null; + if (/^\d+$/.test(s)) return s; // already an objectId + const entry = cache.get(s); + return entry?.objectId || null; +} + +/** + * Set the four Resolved-transition workflow validator fields on a ticket + * before actually calling the transition. This is a JSM workflow post-function + * quirk: the fields aren't on the transition screen but the validator fires + * on execution if they're empty. + * + * Resolution order per field: + * 1. Explicit caller value (name or objectId for CMDB fields) + * 2. Whatever's already on the ticket (only if preserveExisting=true) + * 3. Per-subType default from src/config/ssCloseDefaults.js + * + * Never overwrites a field that step 2 preserved. If a caller value doesn't + * resolve (name not in cache), throws with a clear error naming the field. + * + * @param {string} key Jira issue key + * @param {Object} opts + * @param {string} [opts.component] Jira Component name + * @param {string} [opts.businessService] Business Service name or objectId + * @param {string} [opts.system] System name or objectId + * @param {string} [opts.cause] Cause name or objectId + * @param {string} [opts.subType] SS request subType, used to look up defaults + * @param {boolean} [opts.preserveExisting=true] + * @returns {Promise} { key, applied: {fieldId: value}, skipped: {fieldId: reason} } + */ +export async function setSSValidatorFields(key, opts = {}) { + if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`); + + const { + component, + businessService, + system, + cause, + subType, + preserveExisting = true, + } = opts; + + // Snapshot the current values so we can respect existing triage. + let existing = { component: null, businessService: null, system: null, cause: null }; + try { + const { data } = await jiraClient.get( + `/rest/api/3/issue/${key}?fields=components,customfield_10224,customfield_10225,customfield_10233` + ); + const f = data.fields || {}; + existing = { + component: f.components?.[0]?.name || null, + businessService: f.customfield_10224?.[0]?.objectId || null, + system: f.customfield_10225?.[0]?.objectId || null, + cause: f.customfield_10233?.[0]?.objectId || null, + }; + } catch (e) { + logger.warn('setSSValidatorFields: failed to fetch current ticket state; will apply all fields without preserve-existing check', { + key, error: e.message, + }); + } + + const defaults = getDefaultsForSubType(subType); + const fields = {}; + const applied = {}; + const skipped = {}; + + // ---- Component (Jira native, name-based) ---- + if (component) { + fields.components = [{ name: String(component) }]; + applied.components = component; + } else if (preserveExisting && existing.component) { + skipped.components = `preserved existing '${existing.component}'`; + } else if (defaults.component) { + fields.components = [{ name: defaults.component }]; + applied.components = `${defaults.component} (default${subType ? ` for '${subType}'` : ''})`; + } else { + throw new Error(`No component provided, none on ticket, and no default for subType='${subType || ''}'`); + } + + // ---- Business Service (CMDB customfield_10224) ---- + const bsPlan = resolveOneCmdbField({ + fieldName: 'Business Service', + callerValue: businessService, + existingObjectId: existing.businessService, + defaultValue: defaults.businessService, + subType, + cache: businessServicesCache, + preserveExisting, + }); + if (bsPlan.objectId) { + fields.customfield_10224 = cmdbFieldRef(bsPlan.objectId); + applied.customfield_10224 = bsPlan.applied; + } else { + skipped.customfield_10224 = bsPlan.skipped; + } + + // ---- System (CMDB customfield_10225) ---- + const sysPlan = resolveOneCmdbField({ + fieldName: 'System', + callerValue: system, + existingObjectId: existing.system, + defaultValue: defaults.system, + subType, + cache: systemsCache, + preserveExisting, + }); + if (sysPlan.objectId) { + fields.customfield_10225 = cmdbFieldRef(sysPlan.objectId); + applied.customfield_10225 = sysPlan.applied; + } else { + skipped.customfield_10225 = sysPlan.skipped; + } + + // ---- Cause (CMDB customfield_10233) ---- + const causePlan = resolveOneCmdbField({ + fieldName: 'Cause', + callerValue: cause, + existingObjectId: existing.cause, + defaultValue: defaults.cause, + subType, + cache: causesCache, + preserveExisting, + }); + if (causePlan.objectId) { + fields.customfield_10233 = cmdbFieldRef(causePlan.objectId); + applied.customfield_10233 = causePlan.applied; + } else { + skipped.customfield_10233 = causePlan.skipped; + } + + if (Object.keys(fields).length === 0) { + logger.info('setSSValidatorFields: nothing to write (all fields preserved)', { key, existing }); + return { key, applied, skipped }; + } + + try { + await jiraClient.put(`/rest/api/3/issue/${key}`, { fields }, { + headers: { 'Content-Type': 'application/json' } + }); + logger.info('setSSValidatorFields: applied', { key, applied, skipped }); + return { key, applied, skipped }; + } catch (err) { + logger.error('setSSValidatorFields put failed', { + key, status: err.response?.status, details: err.response?.data + }); + const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); + e.status = err.response?.status; + e.details = err.response?.data; + throw e; + } +} + +function resolveOneCmdbField({ + fieldName, callerValue, existingObjectId, defaultValue, subType, cache, preserveExisting, +}) { + if (callerValue) { + const objectId = resolveCmdbNameOrId(callerValue, cache); + if (!objectId) { + const cacheStatus = cache.status(); + const err = new Error( + `${fieldName} value '${callerValue}' not found in ${cache.name} cache ` + + `(${cacheStatus.count} entries, last synced ${cacheStatus.lastSyncAt || 'never'}). ` + + `Try POST /admin/caches/${cache.name}/refresh, or supply an objectId directly.` + ); + err.status = 400; + throw err; + } + return { objectId, applied: `${callerValue} (objectId ${objectId})` }; + } + if (preserveExisting && existingObjectId) { + return { objectId: null, skipped: `preserved existing objectId ${existingObjectId}` }; + } + if (defaultValue) { + const objectId = resolveCmdbNameOrId(defaultValue, cache); + if (!objectId) { + throw new Error( + `Default ${fieldName} '${defaultValue}' (for subType '${subType || 'none'}') ` + + `not found in ${cache.name} cache. This is a config bug in ssCloseDefaults.js.` + ); + } + return { objectId, applied: `${defaultValue} (default${subType ? ` for '${subType}'` : ''}, objectId ${objectId})` }; + } + throw new Error(`No ${fieldName} provided, none on ticket, and no default for subType='${subType || ''}'`); +} + +/** + * Convenience: find a "closing" transition and execute it. When the target + * transition is done-category, first populates the 4 workflow validator + * fields (components + 3 CMDB customfields) via setSSValidatorFields. If a + * non-done transition is chosen, does NOT send a resolution (fixes Forgejo + * bug #9 where the old code always sent 'Done' and errored on transitions + * whose screen doesn't include the Resolution field). + * + * Backward-compatible with the old signature; new fields are opt-in. + */ +export async function closeTicket(key, opts = {}) { + const { + transitionName, + resolution, + comment, + internal = false, + + // New (opt-in) fields for the SS validator workflow + component, + businessService, + system, + cause, + subType, + preserveExistingClassification = true, + + // Escape hatch: skip the validator-field write. Useful if the caller + // has already set the fields via a separate PATCH. + skipValidatorFields = false, + } = opts; + const transitions = await getTransitions(key); if (transitions.length === 0) { throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`); @@ -286,5 +520,32 @@ export async function closeTicket(key, { transitionName, resolution = 'Done', co } } - return transitionTicket(key, chosen.id, { resolution, comment, internal }); + const isDone = chosen.to?.statusCategory === 'done'; + let validatorFieldsResult = null; + + // Only satisfy the SS validator when we're actually closing. + if (isDone && !skipValidatorFields) { + validatorFieldsResult = await setSSValidatorFields(key, { + component, businessService, system, cause, subType, + preserveExisting: preserveExistingClassification, + }); + } + + // Fix #9: don't send `resolution` unless we're actually transitioning to + // a done-category status. On non-done transitions, `resolution` isn't on + // the transition screen and Jira 400s. + const resolutionToSend = isDone ? (resolution || 'Done') : null; + + const txResult = await transitionTicket(key, chosen.id, { + resolution: resolutionToSend, + comment, + internal, + }); + + return { + ...txResult, + transitionUsed: chosen.name, + newStatusCategory: chosen.to?.statusCategory || null, + validatorFields: validatorFieldsResult, + }; } diff --git a/src/services/jira/storesCache.js b/src/services/jira/storesCache.js index ec8647d..87e9e2a 100644 --- a/src/services/jira/storesCache.js +++ b/src/services/jira/storesCache.js @@ -1,40 +1,15 @@ -// In-memory + on-disk cache of `storeNumber → Assets objectId`. +// Stores cache — instance of the shared Assets object cache factory. // -// Populated via assetsSyncClient (personal PAT). This is the primary lookup -// path used by resolveStoreAssetReference — the raw service-account AQL call -// is only a fallback for the (currently-blocked) scenario where the cache is -// unavailable and the service account has been granted enough permission to -// query the store type directly. +// Store objects live in ObjectType 109 (Store Address / Hierarchy) in schema +// 68. The `label` on those objects is the 5-digit padded store number +// (verified on real objects in the tenant). We also fall back to the "Store +// Number" attribute if the label isn't a plain number. // -// Storage: -// In-memory: Map -// On-disk: data/stores.json (see config.storesCache.path). Gitignored. -// -// Lifecycle: -// init() — called at boot; loads disk cache and kicks off -// background refresh if missing/stale, then schedules -// periodic refresh via setInterval. -// refresh({force}) — full paginated resync via personal PAT. -// get(storeNumber) — sync lookup, returns entry or null. -// status() — for the admin endpoint. -// -// Store objects live in ObjectType 109 in schema 68. The `label` on those -// objects is the padded store number (verified against real objects in the -// tenant); the `Store Number` attribute is a secondary source we fall back to. -import fs from 'node:fs/promises'; -import path from 'node:path'; +// Primary caller: resolveStoreAssetReference in assets.js. Populated by the +// personal-PAT sync path because the service account is filtered out of type +// 109 (see Forgejo issue #1). import config from '../../config/index.js'; -import logger from '../../utilities/logger.js'; -import { assetsAql, isAssetsSyncConfigured, describeSyncIdentity } from './assetsSyncClient.js'; - -const state = { - cache: new Map(), - lastSyncAt: null, - lastError: null, - syncing: false, - scheduleTimer: null, - loadedFromDisk: false, -}; +import { createAssetsObjectCache } from './assetsObjectCache.js'; function normalizeStoreNumber(raw) { if (raw === null || raw === undefined) return null; @@ -44,13 +19,12 @@ function normalizeStoreNumber(raw) { return s.padStart(5, '0'); } -function extractStoreNumberFromObject(obj, storeNumberAttrName) { - // Primary: the label is the padded store number on type 109 objects. +function extractStoreNumberFromObject(obj) { + const storeNumberAttrName = config.jira.assetsStoreNumberAttribute || 'Store Number'; const label = obj?.label || obj?.name; if (label && /^\d+$/.test(String(label).trim())) { return String(label).trim().padStart(5, '0'); } - // Secondary: check the named attribute. const attrs = Array.isArray(obj?.attributes) ? obj.attributes : []; for (const attr of attrs) { const meta = attr?.objectTypeAttribute || attr?.typeAttribute || {}; @@ -67,262 +41,13 @@ function extractStoreNumberFromObject(obj, storeNumberAttrName) { return null; } -/** - * Sync (in-memory) lookup. Returns null on miss; never throws. - */ -export function get(storeNumber) { - const n = normalizeStoreNumber(storeNumber); - if (!n) return null; - return state.cache.get(n) || null; -} +const instance = createAssetsObjectCache({ + name: 'stores', + displayName: 'Stores', + objectTypeId: config.jira.assetsStoreObjectTypeId || '109', + keyFromObject: extractStoreNumberFromObject, + normalizeKey: normalizeStoreNumber, +}); -/** - * Snapshot for the admin status endpoint. Safe to serialize to JSON. - */ -export function status() { - const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null; - return { - storeCount: state.cache.size, - lastSyncAt: state.lastSyncAt, - ageSeconds: lastSyncEpoch ? Math.floor((Date.now() - lastSyncEpoch) / 1000) : null, - syncing: state.syncing, - lastError: state.lastError, - loadedFromDisk: state.loadedFromDisk, - assetsSyncConfigured: isAssetsSyncConfigured(), - syncIdentity: describeSyncIdentity(), - cachePath: config.storesCache.path, - refreshIntervalHours: config.storesCache.refreshIntervalHours, - staleAfterHours: config.storesCache.staleAfterHours, - }; -} - -async function loadFromDisk() { - const cachePath = config.storesCache.path; - try { - const raw = await fs.readFile(cachePath, 'utf8'); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object' && parsed.entries) { - state.cache = new Map(Object.entries(parsed.entries)); - state.lastSyncAt = parsed.lastSyncAt || null; - state.loadedFromDisk = true; - logger.info('Loaded stores cache from disk', { - path: cachePath, - storeCount: state.cache.size, - lastSyncAt: state.lastSyncAt, - }); - return true; - } - logger.warn('Stores cache file present but shape unexpected; ignoring', { path: cachePath }); - } catch (e) { - if (e.code === 'ENOENT') { - logger.info('No stores cache on disk yet; will populate on first sync', { path: cachePath }); - } else { - logger.warn('Failed to load stores cache from disk', { path: cachePath, error: e.message }); - } - } - return false; -} - -async function saveToDisk() { - const cachePath = config.storesCache.path; - const dir = path.dirname(cachePath); - try { - await fs.mkdir(dir, { recursive: true }); - const payload = { - version: 1, - lastSyncAt: state.lastSyncAt, - storeCount: state.cache.size, - entries: Object.fromEntries(state.cache), - }; - // Atomic-ish write: to tmp then rename, avoids readers seeing a half-written file. - const tmp = `${cachePath}.tmp`; - await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8'); - await fs.rename(tmp, cachePath); - logger.debug('Persisted stores cache to disk', { path: cachePath, storeCount: state.cache.size }); - } catch (e) { - logger.warn('Failed to persist stores cache to disk', { path: cachePath, error: e.message }); - } -} - -/** - * Full paginated resync of all objects in the configured store object type - * via the personal PAT. Rebuilds the in-memory cache atomically (only swaps - * once every page has been read successfully) and then writes to disk. - * - * Never called reentrantly — if a refresh is already in flight, returns - * { skipped: true, reason: 'already_syncing' }. - */ -export async function refresh({ force = false } = {}) { - if (state.syncing) { - logger.info('Stores cache refresh already in progress; ignoring duplicate call'); - return { skipped: true, reason: 'already_syncing' }; - } - if (!isAssetsSyncConfigured()) { - return { skipped: true, reason: 'not_configured' }; - } - - state.syncing = true; - state.lastError = null; - const started = Date.now(); - const objectTypeId = config.jira.assetsStoreObjectTypeId || '109'; - const storeNumberAttr = config.jira.assetsStoreNumberAttribute || 'Store Number'; - const pageSize = config.storesCache.pageSize || 500; - const maxPages = config.storesCache.maxPages || 200; // safety cap; 200*500 = 100k stores - - try { - const newCache = new Map(); - // Atlassian Assets `/object/aql` paginates via startAt + maxResults + - // isLast. Some responses also cap `total` at 1000 as a hint, so we - // trust isLast (or an empty/short page) rather than the reported total. - let startAt = 0; - let page = 0; - let seenTotal = 0; - let expectedTotal = null; - let orphaned = 0; - - while (page < maxPages) { - const r = await assetsAql(`objectTypeId = ${objectTypeId}`, { - startAt, - maxResults: pageSize, - includeAttributes: true, - }); - - if (r.status !== 200) { - const bodySnippet = typeof r.data === 'object' - ? JSON.stringify(r.data).slice(0, 400) - : String(r.data).slice(0, 400); - throw new Error( - `Assets AQL page ${page + 1} (startAt=${startAt}) returned HTTP ${r.status} (${r.statusText}). Body: ${bodySnippet}` - ); - } - - const values = Array.isArray(r.data?.values) ? r.data.values : []; - const returnedMaxResults = typeof r.data?.maxResults === 'number' ? r.data.maxResults : pageSize; - if (expectedTotal === null && typeof r.data?.total === 'number') { - expectedTotal = r.data.total; - } - - for (const obj of values) { - const storeNumber = extractStoreNumberFromObject(obj, storeNumberAttr); - if (!storeNumber || !obj?.id) { - orphaned++; - continue; - } - newCache.set(storeNumber, { - objectId: String(obj.id), - objectKey: obj.objectKey || null, - label: obj.label || obj.name || null, - syncedAt: new Date().toISOString(), - }); - } - seenTotal += values.length; - page++; - - const isLast = r.data?.isLast === true - || values.length === 0 - || values.length < returnedMaxResults; - - logger.debug('Stores cache sync page', { - page, - startAt, - returnedMaxResults, - rowsThisPage: values.length, - seenTotal, - expectedTotal, - mappedSoFar: newCache.size, - orphanedSoFar: orphaned, - isLast, - }); - - if (isLast) break; - startAt += values.length; - } - - if (page >= maxPages) { - logger.warn('Stores cache sync hit page cap; some stores may be missing', { - maxPages, - pageSize, - seenTotal, - }); - } - - // Only swap once we've read every page successfully. - state.cache = newCache; - state.lastSyncAt = new Date().toISOString(); - await saveToDisk(); - - const durationMs = Date.now() - started; - logger.info('Stores cache refresh complete', { - storeCount: state.cache.size, - orphaned, - pagesRead: page, - durationMs, - forced: force, - }); - - return { - ok: true, - storeCount: state.cache.size, - orphaned, - pagesRead: page, - durationMs, - }; - } catch (e) { - state.lastError = { message: e.message, at: new Date().toISOString() }; - logger.error('Stores cache refresh failed', { error: e.message }); - throw e; - } finally { - state.syncing = false; - } -} - -/** - * Load the disk cache, kick off a background refresh if missing or stale, and - * schedule periodic refreshes. Idempotent — safe to call more than once. - * - * Never throws. Individual failures are logged and surfaced through status(). - */ -export async function init() { - if (!config.storesCache.enabled) { - logger.info('Stores cache disabled via config; skipping init'); - return; - } - - await loadFromDisk(); - - const staleAfterMs = (config.storesCache.staleAfterHours || 48) * 3600 * 1000; - const shouldRefreshNow = !state.lastSyncAt - || (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs; - - if (shouldRefreshNow && isAssetsSyncConfigured()) { - logger.info('Stores cache is missing or stale; starting background refresh'); - refresh().catch(e => logger.error('Initial stores cache refresh failed', { error: e.message })); - } else if (shouldRefreshNow) { - logger.warn('Stores cache is missing or stale but assets sync is not configured; skipping initial refresh'); - } - - const intervalHours = config.storesCache.refreshIntervalHours; - if (intervalHours > 0) { - if (state.scheduleTimer) clearInterval(state.scheduleTimer); - state.scheduleTimer = setInterval(() => { - if (!isAssetsSyncConfigured()) return; - refresh().catch(e => logger.error('Scheduled stores cache refresh failed', { error: e.message })); - }, intervalHours * 3600 * 1000); - state.scheduleTimer.unref?.(); // don't block process exit for tests - logger.info('Stores cache: periodic refresh scheduled', { intervalHours }); - } -} - -/** - * Test / shutdown hook. Clears the schedule timer and the in-memory map. - * Does NOT delete the on-disk cache. - */ -export function shutdown() { - if (state.scheduleTimer) clearInterval(state.scheduleTimer); - state.scheduleTimer = null; - state.cache = new Map(); - state.lastSyncAt = null; - state.lastError = null; - state.syncing = false; - state.loadedFromDisk = false; -} +export const { get, status, refresh, init, shutdown } = instance; +export default instance; diff --git a/src/services/jira/systemsCache.js b/src/services/jira/systemsCache.js new file mode 100644 index 0000000..3b8025f --- /dev/null +++ b/src/services/jira/systemsCache.js @@ -0,0 +1,30 @@ +// Systems cache — Assets ObjectType 103 in schema 68. +// +// Backs the `customfield_10225 System` validator that fires on the Resolved +// transition of SS tickets. Populated via the personal PAT sync. +// +// Examples of real values observed on closed SS tickets: "UKG Pro WFM". +import { createAssetsObjectCache } from './assetsObjectCache.js'; + +function normalizeName(raw) { + if (raw === null || raw === undefined) return null; + const s = String(raw).trim().toLowerCase(); + return s || null; +} + +function keyFromObject(obj) { + const label = obj?.label || obj?.name; + if (!label) return null; + return String(label).trim().toLowerCase(); +} + +const instance = createAssetsObjectCache({ + name: 'systems', + displayName: 'Systems', + objectTypeId: '103', + keyFromObject, + normalizeKey: normalizeName, +}); + +export const { get, status, refresh, init, shutdown } = instance; +export default instance; diff --git a/src/services/jiraService.js b/src/services/jiraService.js index f03fd01..c776d2a 100644 --- a/src/services/jiraService.js +++ b/src/services/jiraService.js @@ -25,8 +25,15 @@ export { getTransitions, transitionTicket, closeTicket, + setSSValidatorFields, } from './jira/issues.js'; +export { + confirmFixed, + customerCancelled, + markDuplicate, +} from './jira/closeHelpers.js'; + export { fetchPublicComments, addComment, @@ -45,9 +52,22 @@ export { probeAssetsForStore, } from './jira/assets.js'; -// Stores cache (Assets store number -> objectId), fed by a personal-PAT sync -// to work around the service-account permission block on Object Type 109. +// Assets object caches (Assets objectType -> in-memory map). Fed by a +// personal-PAT sync to work around the service-account permission block on +// schema 68. See services/jira/caches.js for the barrel and +// assetsObjectCache.js for the shared factory. +// - stores (objectType 109) — resolveStoreAssetReference +// - businessServices (objectType 100) — SS close validator field +// - systems (objectType 103) — SS close validator field +// - causes (objectType 107) — SS close validator field export * as storesCache from './jira/storesCache.js'; +export { + allCaches, + cachesByName, + businessServicesCache, + systemsCache, + causesCache, +} from './jira/caches.js'; export { isAssetsSyncConfigured, describeSyncIdentity } from './jira/assetsSyncClient.js'; export { @@ -68,7 +88,13 @@ import { getTransitions, transitionTicket, closeTicket, + setSSValidatorFields, } from './jira/issues.js'; +import { + confirmFixed, + customerCancelled, + markDuplicate, +} from './jira/closeHelpers.js'; import { fetchPublicComments, addComment, @@ -103,5 +129,9 @@ export default { getTransitions, transitionTicket, closeTicket, + setSSValidatorFields, + confirmFixed, + customerCancelled, + markDuplicate, jiraClient, };