Script examples
Each Oktul Code Studio script here is whole, with its settings at the top, a sandbox run that follows the production path, and a report of every item it leaves alone, as writing scripts describes. Copy one into a space, change its settings and run it in the sandbox first.
Raise the number at the end of a summary
A small script any trigger can start: the console with a test event, a listener on a work item, or a Jira automation rule. It reads one field, writes one field and returns what changed.
- Test event:
{ "key": "FIN-12" }. Without one, the script reads the work item key from the listener’s or the rule’s event, and then from the script variableWORK_ITEM_KEY. - What it writes: “Order a laptop” becomes “Order a laptop 1”, “Order a laptop 1” becomes “Order a laptop 2”, and “Build 009” becomes “Build 010”, its width kept. A summary that would pass Jira’s 255 characters is refused before the edit.
- Sandbox: the summary is read for real, and the edit is listed under Planned changes.
nextSummary is exported, so another script can import it and a test can call it alone.
const MAX_SUMMARY = 255;
interface Event { key?: string; issueKey?: string; issue?: { key?: string };}
export function nextSummary(summary: string): string { const match = /^(.*?)(\d+)\s*$/.exec(summary); if (!match) return `${summary.trimEnd()} 1`; return `${match[1]}${plusOne(match[2])}`;}
function plusOne(digits: string): string { const out = digits.split(''); for (let i = out.length - 1; i >= 0; i--) { if (out[i] !== '9') { out[i] = String(Number(out[i]) + 1); return out.join(''); } out[i] = '0'; } return `1${out.join('')}`;}
export default async function (event: Event | null, context: ScriptContext) { const key = event?.key ?? event?.issueKey ?? event?.issue?.key ?? context.environment.vars.WORK_ITEM_KEY; if (!key) throw new Error('Name the work item: a Test event such as { "key": "FIN-12" }, or the script variable WORK_ITEM_KEY.'); if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(key)) throw new Error(`${key} is not a work item key, such as FIN-12.`);
const item = await jira.get(`/rest/api/3/issue/${key}`, { query: { fields: 'summary' } }); const before: string = item.fields.summary; const after = nextSummary(before); if (after.length > MAX_SUMMARY) throw new Error(`The new summary would be longer than Jira's ${MAX_SUMMARY} characters.`);
await jira.put(`/rest/api/3/issue/${key}`, { fields: { summary: after } }); log.info(`${key}: "${before}" becomes "${after}"`); return { key, before, after };}Move Assets field values to a new object tree
A migration across every work item: five Assets object fields are moved from an old object tree to a new one in the same schema, so the old tree can be deleted without work items losing their values. It uses:
- the new tree read once, by schema and top object type, with every object indexed by id and label;
- one search over every work item with any of the fields set, following
nextPageToken; - unknown objects looked up 50 at a time with AQL, never one request each;
- the first production edit made alone and read back, and the run stopped if Jira kept nothing;
- the remaining edits four at a time, each read back, with retries on 429 and 5xx;
- a field Jira refuses on one work item reported, and the work item’s other fields still moved;
- every object it cannot move listed with the work item, field and reason, in the log and the result, so a second run after a fix changes only what is still old.
Each old object is matched to its namesake, the object in the new tree with exactly the same label.
- Kept: objects already in the new tree. A namesake already on the field is not added twice.
- Nothing is guessed. No namesake, more than one, gone from Assets, or from another schema: the object stays on the field and is reported.
- Labels are compared after Unicode normalisation and trimming. When only case or spacing differs, the report names the near match.
| Setting | What it holds |
|---|---|
FIELDS |
Each Assets object field to move, by id, with the name the report uses: { customfield_<id>: 'Name' } |
SCHEMA |
The object schema that holds both trees, by name or key |
NEW_TREE_TOP_TYPE |
The new tree’s top object type, by name, or by id when two types share the name; the run stops and lists them if they do |
ONLY_WORK_ITEMS |
JQL that narrows the run, such as key = ABC-123; empty for every work item |
PARALLEL_EDITS |
Work items edited at once after the first edit was read back |
How to run it. Fill in FIELDS with each field’s id and name, SCHEMA with the schema’s name or key, and
NEW_TREE_TOP_TYPE with the new tree’s top object type. Run it in the sandbox: the
log lists every replacement and Planned changes every edit. Then run it in production as a Jira
administrator who can see the schema in Assets, first with ONLY_WORK_ITEMS = 'key = ABC-123',
and look at that work item in Jira. Then run it for every work item, and again until the result
lists nothing.
- Silent: the edits notify nobody, and reach closed work items and fields off the edit screen, as Jira allows an administrator.
- Each edit is a work item update. Turn off automation rules on these fields for the run.
- Only visible, unarchived work items are found. Compare
workItemsFoundwith the result’sjqlin Jira.
| Function | What it does |
|---|---|
loadNewTree |
Finds the schema and the top type, collects every type under it, and reads every object of those types once, indexed by id and by label |
lookUp |
Reads the objects outside the new tree 50 at a time with objectId IN (…); an id Assets does not answer stays unknown and is reported |
plan |
Works out one work item’s new field values and the moves in them, and reports what cannot move |
editWorkItem |
Sends the edit, sends it again without a field Jira refuses, and in production reads the work item back; a first edit Jira kept nothing of stops the run |
send, read |
Every request, retried on 429 and 5xx with Retry-After honoured; read throws on a refusal the run cannot do without |
pages, aql |
Every page of an Assets list, stopping if Assets repeats a page |
refs, refTo |
An Assets field’s value as { workspaceId, id, objectId }, and an object in that shape |
fitted |
The failures for the result, cut to 50,000 characters; the log keeps every one |
const FIELDS: Record<string, string> = {};const SCHEMA = '';const NEW_TREE_TOP_TYPE = '';const ONLY_WORK_ITEMS = '';const PARALLEL_EDITS = 4;
interface Ref { workspaceId: string; id: string; objectId: string;}
interface AssetsObject { id: string; globalId?: string; workspaceId: string; objectKey: string; label: string; objectType: { id: string; name: string; objectSchemaId: string };}
interface ObjectType { id: string; name: string; parentObjectTypeId?: number | string | null;}
interface WorkItem { id: string; key?: string; fields?: Record<string, unknown>;}
interface NewTree { description: string; schemaId: string; typeIds: Set<string>; byId: Map<string, AssetsObject>; byLabel: Map<string, AssetsObject[]>; byLooseLabel: Map<string, AssetsObject[]>;}
interface Entry { ref: Ref; object: AssetsObject | null;}
interface Move { from: AssetsObject; to: AssetsObject;}
interface FieldEdit { before: string; entries: Entry[]; moves: Move[];}
interface Edit { key: string; fields: Record<string, FieldEdit>;}
interface Failure { key: string; field: string; object: string; reason: string;}
type Fail = (failure: Failure) => void;
interface Answer { status: number; body: any;}
const FIELD_IDS = Object.keys(FIELDS);const JQL = `(${FIELD_IDS.map((id) => `cf[${id.replace('customfield_', '')}] is not EMPTY`).join(' OR ')})${ONLY_WORK_ITEMS.trim() ? ` AND (${ONLY_WORK_ITEMS.trim()})` : ''} ORDER BY key ASC`;const EDIT_QUERY = { notifyUsers: false, overrideEditableFlag: true, overrideScreenSecurity: true };const RESULT_FAILURES_LIMIT = 50_000;
export default async function (_event: unknown, context: ScriptContext) { if (FIELD_IDS.length === 0 || !SCHEMA.trim() || !NEW_TREE_TOP_TYPE.trim()) throw new Error('Fill in FIELDS, SCHEMA and NEW_TREE_TOP_TYPE at the top of the script.'); const sandbox = context.mode === 'sandbox';
const tree = await loadNewTree(); log.info(`New tree: ${tree.description}.`); const known = new Map<string, AssetsObject | null>(); const failures: Failure[] = []; const fail: Fail = (failure) => { failures.push(failure); log.warn(line(failure)); }; const totals = { found: 0, edited: 0, moved: 0 };
const apply = async (edit: Edit, first: boolean): Promise<boolean> => { const moved = await editWorkItem(edit, tree, fail, first, sandbox); if (moved.length === 0) return false; totals.edited++; totals.moved += moved.length; log.info(`${edit.key}: ${moved.map(({ field, move }) => `${FIELDS[field]}: ${move.from.label} (${move.from.objectKey} → ${move.to.objectKey})`).join('; ')}`); return true; }; let proven = sandbox;
let nextPageToken: string | undefined; do { const page = await read<{ issues?: WorkItem[]; nextPageToken?: string }>(jira, 'POST', '/rest/api/3/search/jql', { body: { jql: JQL, fields: ['key', ...FIELD_IDS], maxResults: 100, nextPageToken }, }); const items = page.issues ?? []; totals.found += items.length;
const unknown = new Set<string>(); for (const item of items) { for (const field of FIELD_IDS) { for (const ref of refs(item.fields?.[field]) ?? []) if (!tree.byId.has(ref.objectId) && !known.has(ref.objectId)) unknown.add(ref.objectId); } } await lookUp([...unknown], known);
const edits = items.map((item) => plan(item, tree, known, fail)).filter((edit): edit is Edit => edit !== null); let next = 0; while (!proven && next < edits.length) proven = await apply(edits[next++], true); await inParallel(edits.slice(next), PARALLEL_EDITS, async (edit) => { await apply(edit, false); }); nextPageToken = page.nextPageToken; } while (nextPageToken);
failures.sort(byWorkItem); log.info(`Work items found: ${totals.found}. ${sandbox ? 'To edit' : 'Edited'}: ${totals.edited}. Objects ${sandbox ? 'to move' : 'moved'}: ${totals.moved}. Not moved: ${failures.length}.`); return { mode: context.mode, jql: JQL, newTree: tree.description, workItemsFound: totals.found, [sandbox ? 'workItemsToEdit' : 'workItemsEdited']: totals.edited, [sandbox ? 'objectsToMove' : 'objectsMoved']: totals.moved, notMoved: failures.length, ...fitted(failures.map(line)), };}
async function loadNewTree(): Promise<NewTree> { const wanted = SCHEMA.trim(); const top = NEW_TREE_TOP_TYPE.trim(); const schemas = await pages<{ id: string; name: string; objectSchemaKey: string }>('GET', '/objectschema/list'); const schema = only( schemas.filter((s) => s.name === wanted || s.objectSchemaKey === wanted), `object schema named ${wanted} that this run can see`, (s) => `${s.name} (key ${s.objectSchemaKey}, id ${s.id})`, ); const types = await read<ObjectType[]>(assets, 'GET', `/objectschema/${schema.id}/objecttypes/flat`); const root = only( types.filter((t) => String(t.id) === top || t.name === top), `object type ${top} in ${schema.name}`, (t) => `${t.name} (id ${t.id})`, );
const typeIds = new Set([String(root.id)]); for (let grew = true; grew; ) { grew = false; for (const type of types) { if (type.parentObjectTypeId != null && typeIds.has(String(type.parentObjectTypeId)) && !typeIds.has(String(type.id))) { typeIds.add(String(type.id)); grew = true; } } }
const objects = await aql(`objectSchemaId = ${schema.id} AND objectTypeId IN (${[...typeIds].join(', ')})`); if (objects.length === 0) throw new Error(`The new tree under ${root.name} holds no objects this run can see: check NEW_TREE_TOP_TYPE, and that the run may read the schema.`); const tree: NewTree = { description: `${root.name} (id ${root.id}) and ${typeIds.size - 1} object types under it in ${schema.name}, ${objects.length} objects`, schemaId: String(schema.id), typeIds, byId: new Map(), byLabel: new Map(), byLooseLabel: new Map(), }; for (const object of objects) { tree.byId.set(String(object.id), object); push(tree.byLabel, exact(object.label), object); push(tree.byLooseLabel, loose(object.label), object); } return tree;}
async function lookUp(ids: string[], known: Map<string, AssetsObject | null>): Promise<void> { for (const id of ids) known.set(id, null); const numeric = ids.filter((id) => /^\d+$/.test(id)); for (let i = 0; i < numeric.length; i += 50) { for (const object of await aql(`objectId IN (${numeric.slice(i, i + 50).join(', ')})`)) known.set(String(object.id), object); }}
function plan(item: WorkItem, tree: NewTree, known: Map<string, AssetsObject | null>, fail: Fail): Edit | null { const key = item.key ?? item.id; const edit: Edit = { key, fields: {} }; for (const field of FIELD_IDS) { const value = item.fields?.[field]; const before = refs(value); if (before === null) { fail({ key, field, object: JSON.stringify(value).slice(0, 200), reason: 'the field holds a value this script does not understand, so it is left as it is' }); continue; } const entries: Entry[] = []; const moves: Move[] = []; const keep = (entry: Entry) => { if (!entries.some((held) => held.ref.objectId === entry.ref.objectId)) entries.push(entry); }; for (const ref of before) { const object = tree.byId.get(ref.objectId) ?? known.get(ref.objectId) ?? null; if (object && tree.typeIds.has(String(object.objectType?.id))) { keep({ ref, object }); continue; } let to: AssetsObject | undefined; let reason: string; if (!object) reason = 'Assets has no object with this id that the run can see; it may have been deleted'; else if (String(object.objectType?.objectSchemaId) !== tree.schemaId) reason = 'the object is in another object schema, not in the old tree'; else if (!exact(object.label)) reason = 'the object has no label to match'; else { const namesakes = tree.byLabel.get(exact(object.label)) ?? []; const near = tree.byLooseLabel.get(loose(object.label)) ?? []; if (namesakes.length === 1) to = namesakes[0]; reason = namesakes.length > 1 ? `${namesakes.length} objects in the new tree have this label: ${namesakes.map(describe).join(', ')}` : `no object in the new tree has this label${near.length > 0 ? `; differs only in letter case or spaces: ${near.map(describe).join(', ')}` : ''}`; } if (object && to) { keep({ ref: refTo(to), object: to }); moves.push({ from: object, to }); } else { fail({ key, field, object: object ? describe(object) : `object ${ref.objectId}`, reason }); keep({ ref, object }); } } if (moves.length > 0) { edit.fields[field] = { before: before.map((ref) => describeRef(ref, tree, known)).join(', '), entries, moves }; } } return Object.keys(edit.fields).length > 0 ? edit : null;}
async function editWorkItem(edit: Edit, tree: NewTree, fail: Fail, first: boolean, sandbox: boolean): Promise<{ field: string; move: Move }[]> { let names = Object.keys(edit.fields); const report = (fields: string[], reason: string) => { for (const field of fields) for (const move of edit.fields[field].moves) fail({ key: edit.key, field, object: describe(move.from), reason }); }; for (;;) { const update = Object.fromEntries(names.map((field) => [field, [{ set: edit.fields[field].entries.map((entry) => entry.ref) }]])); const answer = await send(jira, 'PUT', `/rest/api/3/issue/${edit.key}`, { query: EDIT_QUERY, body: { update } }); if (answer.status < 300) break; const errors: Record<string, string> = answer.body?.errors ?? {}; const refused = names.filter((field) => errors[field]); const others = [...(answer.body?.errorMessages ?? []), ...Object.entries(errors).filter(([field]) => !names.includes(field)).map(([field, message]) => `${field}: ${message}`)]; if (refused.length === 0 || others.length > 0) { report(names, `Jira refused the edit (${answer.status}): ${others.join(' ') || summary(answer.body)}`); return []; } for (const field of refused) report([field], `Jira refused the edit: ${errors[field]}`); names = names.filter((field) => !refused.includes(field)); if (names.length === 0) return []; } const moves = names.flatMap((field) => edit.fields[field].moves.map((move) => ({ field, move }))); if (sandbox) return moves;
const after = await send(jira, 'GET', `/rest/api/3/issue/${edit.key}`, { query: { fields: names.join(',') } }); if (after.status >= 300) { report(names, `edited, but reading the work item back answered ${after.status}: check it in Jira`); return []; } const held = new Map(names.map((field) => [field, new Set((refs(after.body?.fields?.[field]) ?? []).map((ref) => ref.objectId))])); const kept = moves.filter(({ field, move }) => held.get(field)!.has(String(move.to.id))); if (first && kept.length === 0) { throw new Error( `${edit.key}: Jira took the run's first edit but kept none of the new objects, so the run stopped before any other work item. ` + `Jira no longer takes the edit as this script sends it, or these fields refuse the new objects. ` + `Before the edit: ${names.map((field) => `${FIELDS[field]} held ${edit.fields[field].before}`).join('; ')}.`, ); } for (const field of names) { for (const entry of edit.fields[field].entries) { if (held.get(field)!.has(entry.ref.objectId)) continue; const isNew = entry.object !== null && tree.typeIds.has(String(entry.object.objectType?.id)); fail({ key: edit.key, field, object: entry.object ? describe(entry.object) : `object ${entry.ref.objectId}`, reason: `${isNew ? 'Jira took the edit but did not keep this new object' : 'Jira removed this object when it saved the field'}; before the edit the field held ${edit.fields[field].before}`, }); } } return kept;}
async function send(client: CcProductClient, method: string, path: string, options: { query?: Record<string, string | number | boolean>; body?: unknown } = {}): Promise<Answer> { for (let attempt = 1; ; attempt++) { const response = await client.request({ method, path, query: options.query, body: options.body }); const busy = response.status === 429 || (response.status >= 500 && response.status !== 501); if (busy && attempt < 6) { await wait(backoff(response.headers['retry-after'], attempt)); continue; } return { status: response.status, body: parse(response.body) }; }}
async function read<T>(client: CcProductClient, method: string, path: string, options?: { query?: Record<string, string | number | boolean>; body?: unknown }): Promise<T> { const answer = await send(client, method, path, options); if (answer.status >= 300) throw new Error(`${method} ${path} answered ${answer.status}: ${summary(answer.body)}`); return answer.body as T;}
async function pages<T extends { id: string }>(method: string, path: string, body?: unknown, query: Record<string, string | number | boolean> = {}): Promise<T[]> { const found: T[] = []; const seen = new Set<string>(); for (let startAt = 0; ; ) { const page = await read<{ values?: T[]; isLast?: unknown }>(assets, method, path, { query: { ...query, startAt, maxResults: 50 }, body }); const values = page.values ?? []; if (values.length > 0 && values.every((value) => seen.has(String(value.id)))) throw new Error(`Assets answered ${path} with the same page twice; the run stopped rather than loop.`); for (const value of values) { if (!seen.has(String(value.id))) found.push(value); seen.add(String(value.id)); } if (values.length === 0 || page.isLast === true || page.isLast === 'true') return found; startAt += values.length; }}
function aql(qlQuery: string): Promise<AssetsObject[]> { return pages<AssetsObject>('POST', '/object/aql', { qlQuery }, { includeAttributes: false });}
function refs(value: unknown): Ref[] | null { if (value === null || value === undefined) return []; const list = Array.isArray(value) ? value : [value]; const out: Ref[] = []; for (const item of list) { const ref = item as Partial<Record<keyof Ref, unknown>> | null; const objectId = String(ref?.objectId ?? ''); if (!ref || !objectId || typeof ref.workspaceId !== 'string') return null; out.push({ workspaceId: ref.workspaceId, id: String(ref.id ?? `${ref.workspaceId}:${objectId}`), objectId }); } return out;}
function refTo(object: AssetsObject): Ref { return { workspaceId: object.workspaceId, id: object.globalId ?? `${object.workspaceId}:${object.id}`, objectId: String(object.id) };}
function exact(label: string | null | undefined): string { return (label ?? '').normalize('NFC').trim();}
function loose(label: string): string { return exact(label).toLowerCase().replace(/\s+/g, ' ');}
function describe(object: AssetsObject): string { return `${object.label} (${object.objectKey})`;}
function describeRef(ref: Ref, tree: NewTree, known: Map<string, AssetsObject | null>): string { const object = tree.byId.get(ref.objectId) ?? known.get(ref.objectId); return object ? describe(object) : `object ${ref.objectId}`;}
function push<K, V>(map: Map<K, V[]>, key: K, value: V): void { const list = map.get(key); if (list) list.push(value); else map.set(key, [value]);}
function only<T>(found: T[], what: string, name: (item: T) => string): T { if (found.length === 1) return found[0]; if (found.length === 0) throw new Error(`There is no ${what}.`); throw new Error(`There is more than one ${what}: ${found.map(name).join(', ')}. Name it by its id.`);}
async function inParallel<T>(items: T[], width: number, work: (item: T) => Promise<void>): Promise<void> { let next = 0; let failure: unknown = null; const lane = async () => { while (failure === null && next < items.length) { try { await work(items[next++]); } catch (error) { failure ??= error; } } }; await Promise.all(Array.from({ length: Math.min(width, items.length) }, lane)); if (failure !== null) throw failure;}
function backoff(retryAfter: string | undefined, attempt: number): number { const seconds = Number(retryAfter); return (Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 60) : Math.min(2 ** attempt, 30)) * 1000;}
function wait(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms));}
function parse(text: string): any { if (!text) return null; try { return JSON.parse(text); } catch { return text; }}
function summary(body: any): string { if (typeof body === 'string') return body.slice(0, 300) || 'no message'; const parts = [...(body?.errorMessages ?? []), ...Object.entries(body?.errors ?? {}).map(([field, message]) => `${field}: ${message}`)]; if (body?.message) parts.push(String(body.message)); return parts.join(' ') || 'no message';}
function line(failure: Failure): string { return `${failure.key} | ${FIELDS[failure.field]} | ${failure.object} | ${failure.reason}`;}
function byWorkItem(a: Failure, b: Failure): number { const [, ap = a.key, an = '0'] = /^(.*)-(\d+)$/.exec(a.key) ?? []; const [, bp = b.key, bn = '0'] = /^(.*)-(\d+)$/.exec(b.key) ?? []; if (ap !== bp) return ap < bp ? -1 : 1; return Number(an) - Number(bn) || FIELD_IDS.indexOf(a.field) - FIELD_IDS.indexOf(b.field);}
function fitted(lines: string[]): { failures: string[]; failuresOnlyInLog?: number } { let size = 0; const shown = lines.filter((text) => (size += text.length + 4) <= RESULT_FAILURES_LIMIT); return shown.length === lines.length ? { failures: shown } : { failures: shown, failuresOnlyInLog: lines.length - shown.length };}Keep Assets objects in step with a register
A scheduled sync from another system into Assets: one object per person, matched by a unique attribute. It creates what is new, updates only what differs and marks people the register no longer lists, so a run where nothing changed sends no write.
- Connection:
register, whose base URL is the register’s API and which adds its credential. The script never sees it. See connections and variables. - Trigger: a schedule, such as every weekday at 06:00.
- Nothing is deleted. A person the register no longer lists is marked Ended.
| Setting | What it holds |
|---|---|
CONNECTION |
The connection’s key |
PEOPLE_PATH |
The register’s list of people, inside the connection’s base URL |
PEOPLE_TYPE |
The People object type’s id, from Assets |
ATTRIBUTE |
The attribute ids, from the object type’s Attributes tab. personalCode matches a person to an object |
ACTIVE, ENDED |
The status values written |
PARALLEL_WRITES |
Assets writes sent at once |
valueOf reads an attribute’s first value whatever its type, a text, a referenced object’s key or
a status name, and attributes builds the list Assets takes on create and update.
const CONNECTION = 'register';const PEOPLE_PATH = '/people';const PEOPLE_TYPE = '';const ATTRIBUTE = { name: '', personalCode: '', email: '', status: '' };const ACTIVE = 'Active';const ENDED = 'Ended';const PARALLEL_WRITES = 5;
interface Person { givenname: string; surname: string; personalcode: string; email: string;}
interface AssetsObject { id: string; objectKey: string; attributes?: { objectTypeAttributeId: string; objectAttributeValues: { value?: string; referencedObject?: { objectKey: string }; status?: { name: string } }[] }[];}
type Values = Record<keyof typeof ATTRIBUTE, string>;
export default async function (_event: unknown, context: ScriptContext) { if (!PEOPLE_TYPE || Object.values(ATTRIBUTE).some((id) => !id)) throw new Error('Fill in PEOPLE_TYPE and every ATTRIBUTE id at the top of the script.'); const [register, objects] = await Promise.all([ http.get<{ data: Person[] }>(CONNECTION, PEOPLE_PATH), everyObject(`objectTypeId = ${PEOPLE_TYPE}`), ]); const people = (register.data ?? []).filter((person) => person.personalcode); log.info(`Register: ${people.length} people. Assets: ${objects.length} objects.`);
const byCode = new Map(objects.map((object) => [valueOf(object, ATTRIBUTE.personalCode), object])); const listed = new Set(people.map((person) => person.personalcode)); const totals = { created: 0, updated: 0, ended: 0, unchanged: 0 }; const writes: (() => Promise<void>)[] = [];
for (const person of people) { const wanted: Values = { name: `${person.givenname ?? ''} ${person.surname ?? ''}`.trim(), personalCode: person.personalcode, email: person.email ?? '', status: ACTIVE, }; const object = byCode.get(person.personalcode); if (!object) { totals.created++; writes.push(() => assets.post('/object/create', { objectTypeId: PEOPLE_TYPE, attributes: attributes(wanted) })); continue; } const changed = (Object.keys(ATTRIBUTE) as (keyof Values)[]).filter((name) => valueOf(object, ATTRIBUTE[name]) !== wanted[name]); if (changed.length === 0) { totals.unchanged++; continue; } totals.updated++; const only = Object.fromEntries(changed.map((name) => [name, wanted[name]])) as Partial<Values>; writes.push(() => assets.put(`/object/${object.id}`, { objectTypeId: PEOPLE_TYPE, attributes: attributes(only) })); }
for (const [code, object] of byCode) { if (!code || listed.has(code) || valueOf(object, ATTRIBUTE.status) === ENDED) continue; totals.ended++; writes.push(() => assets.put(`/object/${object.id}`, { objectTypeId: PEOPLE_TYPE, attributes: attributes({ status: ENDED }) })); }
for (let i = 0; i < writes.length; i += PARALLEL_WRITES) { await Promise.all(writes.slice(i, i + PARALLEL_WRITES).map((write) => write())); context.checkpoint({ written: i + PARALLEL_WRITES }); } log.info(`Created ${totals.created}, updated ${totals.updated}, ended ${totals.ended}, unchanged ${totals.unchanged}.`); return { mode: context.mode, ...totals };}
async function everyObject(qlQuery: string): Promise<AssetsObject[]> { const found: AssetsObject[] = []; for (let startAt = 0; ; ) { const page = await assets.post('/object/aql', { qlQuery }, { query: { startAt, maxResults: 50, includeAttributes: true } }); const values: AssetsObject[] = page.values ?? []; found.push(...values); if (values.length === 0 || page.isLast === true || page.isLast === 'true') return found; startAt += values.length; }}
function valueOf(object: AssetsObject, attributeId: string): string { const value = object.attributes?.find((attribute) => attribute.objectTypeAttributeId === attributeId)?.objectAttributeValues?.[0]; return value?.referencedObject?.objectKey ?? value?.status?.name ?? value?.value ?? '';}
function attributes(values: Partial<Values>) { return Object.entries(values).map(([name, value]) => ({ objectTypeAttributeId: ATTRIBUTE[name as keyof Values], objectAttributeValues: [{ value }], }));}The writes are the long part of a sync of thousands, so the script checkpoints after each batch. A continuation then starts the script again from the top, reads the register and Assets again and plans again, and since it writes only what differs, nothing already written is written twice.
A scripted number field
The minutes since the work item was last updated. A computed field: set Recompute to about every 15 minutes, since the value moves with time and not only when the work item changes.
export default async function (workItem: { fields?: { updated?: string } }) { const updated = workItem.fields?.updated ? Date.parse(workItem.fields.updated) : NaN; if (Number.isNaN(updated)) return null; return Math.max(0, Math.floor((Date.now() - updated) / 60_000));}A scripted select field
A picked select: people choose from what options() returns, and the default export gives the
default written to a work item whose field is empty. Here there is no default, and options()
gives the choices people and the script pick from.
interface WorkItem { key?: string; fields?: { created?: string };}
export default async function (workItem: WorkItem) { return null;}
export function options() { return ['Low', 'Medium', 'High'];}A dynamic form rule
A field shown and required only when Priority is Highest. The rule names 'priority' by its id in
quotes, so it runs again when Priority changes. ESCALATION_FIELD names the field by its name or
its id.
const ESCALATION_FIELD = 'Escalation reason';
export default async function (form, context) { const highest = form.field('priority').value?.name === 'Highest'; form.field(ESCALATION_FIELD).setVisible(highest).setRequired(highest);}A ScriptRunner Connect script, unchanged
A Connect script keeps its imports: Code Studio serves ./api/jira/cloud,
./api/jsm/cloud/assets and @managed-api/commons-core. This listener comments on a work item
with the label of the Assets object in one of its fields.
import JiraCloud from './api/jira/cloud';import JSMCloudAssets from './api/jsm/cloud/assets';import { NotFoundError } from '@managed-api/commons-core';
export default async function (event, context) { const key = event.issue.key; const field = context.environment.vars.APP_FIELD_ID; const workItem = await JiraCloud.Issue.getIssue({ issueIdOrKey: key, fields: [field] }); const [ref] = workItem.fields[field] ?? []; if (!ref?.objectId) return { key, commented: false };
try { const object = await JSMCloudAssets.Object.getObject({ workspaceId: ref.workspaceId, id: ref.objectId }); await JiraCloud.Issue.Comment.addComment({ issueIdOrKey: key, body: { body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: `Application: ${object.label}` }] }] } }, }); return { key, commented: true }; } catch (error) { if (error instanceof NotFoundError) { log.warn(`${key}: Assets has no object ${ref.objectId} the run can see.`); return { key, commented: false }; } throw error; }}A managed method Code Studio does not list on writing scripts
fails as “is not a function”. Write that call with jira.request instead.
Related
A problem or a question? Write to support@oktul.com or open a request in the Help Center (opens in a new tab). Both open a request with a reference and an SLA.