Writing scripts
An Oktul Code Studio script receives the event that started it and a context, reaches Jira, Confluence, Assets and other systems through a few globals, and returns a result. Built the way this page describes, its sandbox run lists the same writes its production run sends. Every signature is in the script API reference, and whole scripts are in script examples.
The shape of a script
A script is an ES module in TypeScript or JavaScript. Its default export runs, and what it returns is the run’s result.
export default async function (event: unknown, context: ScriptContext) { log.info(`Mode: ${context.mode}`); return { updated: 0 };}eventis what started the run. Its shape depends on the trigger, as the next section lists.contextcarries the run’s id, its mode, the script variables and the checkpoint.- The return value is stored as JSON, cut at 64,000 characters. Return counts, keys and short lists a person reads, never whole work items.
- A thrown error fails the run, with its message and line. Throw with a sentence that says what to fix.
- A module without a default export runs top to bottom, and top-level
awaitworks. - TypeScript is not type checked on the server. Its types are removed, then it runs. The console’s editor checks types and completes the API as you type.
- Imports are other scripts in the space, by relative path (
import { sync } from './lib/sync'), AssemblyScript modules, and ScriptRunner Connect’s modules (see below). There are no npm packages and no Node modules: nofs, noBuffer, noprocess.
What a trigger hands the script
| Started by | event |
Also in context |
|---|---|---|
| Run in the console | The test event typed in the run panel, parsed as JSON, or null |
|
| Listener | The payload Jira’s webhook or Confluence would have sent, with the whole work item or page fetched first: webhookEvent, timestamp, issue_event_type_name, issue (every field), user, changelog, comment, worklog; for Confluence page and userAccountId |
forgeEvent, the raw Forge event |
| Schedule | { type: 'schedule', scheduleId, scheduledFor } |
|
| Jira automation | { type: 'automation', params, issue: { id, key } | null, rule, initiator }. params is the rule’s JSON with smart values filled, or its text when it is not JSON |
|
| API trigger | { method, path, headers, queryString, queryStringParams, sourceIp, body, bodyType }, as ScriptRunner Connect’s HttpEventRequest. A JSON object body’s own fields also sit on event, so a webhook-shaped body reads like a listener’s event |
|
| Scripted field | The default export is called once per work item, as (workItem, context), with workItem.fields holding every field. options(context) is called for the choices |
field: { id, name, type, contextId } |
| Dynamic form | The default export is called as (form, context) in the person’s browser |
view, trigger, projectId, issueTypeId, issueId, accountId, portalId, requestTypeId |
A script that more than one trigger starts reads the work item key defensively, the way the increment example does:
const key = event?.issue?.key ?? event?.key ?? context.environment.vars.WORK_ITEM_KEY;if (!key) throw new Error('Name the work item: a test event such as { "key": "FIN-12" }.');Field values in event.issue.fields are in Jira REST API v3’s shape: a user is
{ accountId, displayName }, a select is { id, value }, a description is an Atlassian document,
an Assets field is a list of { workspaceId, id, objectId }.
Settings at the top, values in variables
- What a person changes before a run goes in named constants at the top: field ids, a schema name, a JQL filter, a batch size. Describe them where the script is shared, as script examples does.
- What differs between sites, or must stay out of the code, goes in
script variables, read as
context.environment.vars.NAME. Each is a string orundefined.
const FIELDS: Record<string, string> = {};const ONLY_WORK_ITEMS = '';Here FIELDS maps each Assets field’s id to the name the report uses, and ONLY_WORK_ITEMS is
JQL that narrows the run, such as key = ABC-123 for a first try.
Check the settings first and throw when one is missing, so a run with an empty setting changes nothing.
Jira and Confluence
jira and confluence take the product’s own REST paths, exactly as Atlassian’s REST
documentation writes them, starting with /. They reach this site directly, as the run’s
principal: no base URL, no token, no cloud id.
const item = await jira.get(`/rest/api/3/issue/${key}`, { query: { fields: 'summary,status' } });await jira.put(`/rest/api/3/issue/${key}`, { fields: { summary: 'New summary' } });const page = await confluence.get(`/wiki/api/v2/pages/${pageId}`, { query: { 'body-format': 'storage' } });-
The verbs answer the parsed body and throw
HttpErroron a status of 400 or more, withstatus,headersandbody. A 204 answersnull. -
request()never throws on a status. Use it where a refusal is an answer the script handles, such as a 404 for an entity property that is not set, or a field Jira refuses on one work item:const answer = await jira.request({ method: 'GET', path: `/rest/api/3/issue/${key}/properties/sync` });if (answer.status === 404) return null;const value = JSON.parse(answer.body).value;answer.bodyis text. Parse it yourself. -
A string body is sent as text, anything else as JSON.
-
Query values are encoded for you. Pass them in
query, never in the path.
Searching work items
GET /rest/api/3/search is removed by Atlassian. Search with POST /rest/api/3/search/jql, ask
only for the fields the script reads, and follow nextPageToken to the end:
let nextPageToken: string | undefined;do { const page = await jira.post('/rest/api/3/search/jql', { jql, fields: ['key', 'summary'], maxResults: 100, nextPageToken }); for (const item of page.issues ?? []) handle(item); nextPageToken = page.nextPageToken;} while (nextPageToken);The search has no total. Count as you go, or ask POST /rest/api/3/search/approximate-count.
Add properties: ['name'] to read entity properties in the same request rather than one read per
work item.
Editing work items
PUT /rest/api/3/issue/{key}with{ fields: { … } }replaces a value, and{ update: { field: [{ set: … }] } }sets one through Jira’s update operations.- An administrator’s run can add the query
{ notifyUsers: false, overrideEditableFlag: true, overrideScreenSecurity: true }: no notification, closed work items, and fields missing from the edit screen. - A paragraph field and a comment are Atlassian documents in v3:
{ type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] }. Plain text goes to/rest/api/2/issue/{key}instead. - A field named by its name needs its id: read
GET /rest/api/3/fieldonce and keep aMap. - A transition is
GET …/transitions, thenPOST …/transitionswith the id of the one whoseto.namematches.
Every edit is a work item update. Automation rules and listeners that react to the field run once per edited work item; say so at the top of a script that edits many.
Assets
assets takes paths relative to the site’s Assets workspace, which Code Studio finds itself:
/object/123, not /jsm/assets/workspace/<id>/v1/object/123. It runs as the run’s principal,
who must be able to see the schema in Assets.
| Path | What it answers |
|---|---|
GET /objectschema/list |
The object schemas, paged: id, name, objectSchemaKey |
GET /objectschema/{id}/objecttypes/flat |
Every object type of a schema: id, name, parentObjectTypeId |
POST /object/aql with { qlQuery } |
The objects an AQL query finds, paged with startAt and maxResults in the query; includeAttributes: false when the script needs only labels and keys |
GET /object/{id} |
One object: id, objectKey, label, objectType, globalId, workspaceId |
POST /object/create |
A new object: { objectTypeId, attributes } |
PUT /object/{id} |
Changed attributes: { objectTypeId, attributes } |
Read every page of a list. A page carries values and isLast, and Atlassian’s own example
writes isLast as text, so test for both:
async function aql(qlQuery: string) { const found = []; for (let startAt = 0; ; ) { const page = await assets.post('/object/aql', { qlQuery }, { query: { startAt, maxResults: 50, includeAttributes: false } }); const values = page.values ?? []; found.push(...values); if (values.length === 0 || page.isLast === true || page.isLast === 'true') return found; startAt += values.length; }}Attributes are written by attribute id, which the object type’s Attributes tab in Assets shows. Keep the ids in settings at the top. A reference attribute takes the referenced object’s key as its value, and a list of them sets a multi-reference:
await assets.put(`/object/${object.id}`, { objectTypeId: PEOPLE_TYPE, attributes: [ { objectTypeAttributeId: ATTRIBUTE.firstName, objectAttributeValues: [{ value: person.givenname }] }, { objectTypeAttributeId: ATTRIBUTE.contracts, objectAttributeValues: contractKeys.map((key) => ({ value: key })) }, ],});Reading one back, an attribute’s value is in value, referencedObject.objectKey, user.key or
status.name, depending on its type. Compare before writing and send only what differs.
An Assets field on a work item holds a list of { workspaceId, id, objectId }, where id is
<workspaceId>:<objectId>. Set it with the same shape:
await jira.put(`/rest/api/3/issue/${key}`, { update: { [ASSETS_FIELD]: [{ set: [{ workspaceId, id: `${workspaceId}:${objectId}`, objectId }] }] },});Read the work item back after the first edit of a run: Jira can answer 204 and keep nothing it does not recognise.
Other systems
- Through a connection, by its key:
http.get('graph', '/groups'). The path is resolved inside the connection’s base URL and the connection adds its credential. The script never sees it, so never write a token request for a system a connection covers. - Directly, with no credential:
fetch(url)to a host an administrator approved. - Not this site through the gateway. A script that asks
auth.atlassian.comfor a token to callapi.atlassian.com/ex/jira/<cloud id>/…is calling this site: usejiraorassetswith the path after the cloud id, and drop the token code.
Every verb, body type and error, and how to reach Atlassian endpoints jira cannot, is in
requests to other systems.
The sandbox and production
Every run has a mode, context.mode. In the sandbox every read is real and every write is
recorded and answered with a simulated response, listed under Planned changes. Production sends
both. Build the script so that its sandbox run follows the same path:
-
Never depend on a write’s response body. In the sandbox it is simulated. Keep what you sent.
-
Branch on the mode only where a write is read back. A sandbox run has nothing to read back:
const sandbox = context.mode === 'sandbox';if (!sandbox) await checkKept(key); -
Prove the first write in production. Make the run’s first edit alone, read it back, and stop the run with what the fields held before if Jira kept nothing. Only then write the rest in parallel.
-
Run on one work item first. A JQL setting such as
key = ABC-123lets a person try one production edit and look at it in Jira before the whole run. -
storekeeps its own values in the sandbox, so a test run never changes what production reads.
Requests, time and parallel work
A run sends at most 5,000 requests to this site and 1,500 elsewhere, and its invocation lasts 900 seconds before it continues itself (see limits).
-
Fewest requests. Search with only the fields you read, look up objects 50 at a time with
objectId IN (…), read each person or field id once and keep aMap. -
Parallel, a few at a time. Jira and Assets limit how fast a site may be called. Run at most 4 to 10 requests at once, never
Promise.allover hundreds:async function inParallel<T>(items: T[], width: number, work: (item: T) => Promise<void>) {let next = 0;const lane = async () => { while (next < items.length) await work(items[next++]); };await Promise.all(Array.from({ length: Math.min(width, items.length) }, lane));} -
Wait and retry on 429 and 5xx. Use
request(), readretry-afterin seconds, and wait 2, 4, 8, 16 and 30 seconds otherwise, at most six attempts:for (let attempt = 1; ; attempt++) {const answer = await jira.request({ method, path, body });const busy = answer.status === 429 || (answer.status >= 500 && answer.status !== 501);if (!busy || attempt === 6) return answer;const seconds = Number(answer.headers['retry-after']);await new Promise((r) => setTimeout(r, (seconds > 0 ? Math.min(seconds, 60) : Math.min(2 ** attempt, 30)) * 1000));} -
Checkpoint long loops. A run that outlasts its invocation continues in a new one, which replays the script against the record of every answer it was given, so nothing is sent twice.
context.checkpoint(state)drops the record before it: the continuation calls the default export again from the top, withcontext.resumedFromholding the state. So a script that checkpoints either starts fromcontext.resumedFrom, or is safe to run again from the top because it writes only what is still old:const start = context.resumedFrom?.next ?? 0;for (let i = start; i < items.length; i += 25) {await handle(items.slice(i, i + 25));context.checkpoint({ next: i + 25 });}
Logs and results
log.debug,log.info,log.warnandlog.errorwrite the run’s log, andconsole.*writes the same log. One line per step, with counts. A run keeps 5,000 lines of up to 4,000 characters.- Secret script variables are blanked out of every log line, result and error.
- Report what the script did not do. A script that skips an item says which, and why, as a
log.warnand in the result, so a person can fix it and run the script again. - Make a second run safe. Change only what is still old, so running the script again after a fix finishes the job rather than repeating it.
ScriptRunner Connect scripts
A ScriptRunner Connect script runs unchanged. These imports are served by Code Studio when the space has no file at that path:
| Import | What it is |
|---|---|
./api/jira/cloud |
Jira’s managed API: Issue.getIssue, createIssue, editIssue, deleteIssue, assignIssue, Issue.Comment, Issue.Link, Issue.Transition, Issue.Search.searchByJql, Project.getProject, User.getUser, Myself.getCurrentUser, fetch |
./api/jsm/cloud/assets |
Assets’ managed API: Workspace.getWorkspaces, Object.getObject, createObject, updateObject, deleteObject, getObjectsByAql, getObjectAttributes, ObjectType.getObjectType, getObjectTypeAttributes |
./api/<key> |
A generic connector: fetch(path, init) through the Code Studio connection with that key |
@managed-api/commons-core |
The errors the managed API throws: NotFoundError, ForbiddenError, BadRequestError, UnauthorizedError, TooManyRequestsError, ServerError |
The path may sit deeper, ../../../api/jira/cloud, as Connect workspaces nest it. A method not
listed here does not exist and fails as “is not a function”; write it with jira.request. Type
imports such as @sr-connect/jira-cloud/events are removed with the other types. A shared
Utils file is another script in the space.
Before a script is done
- Settings at the top, named for what they hold; secrets in a connection, never in the code.
- Missing settings refused before any request.
- Searches ask for only the fields read, and every list is read to its last page.
- No write depends on a write’s response; the first production write is read back.
- Requests in parallel at most a few at a time, with retries on 429 and 5xx.
context.checkpointin any loop over hundreds of items.- Every item not changed is reported with its reason, in the log and the result.
- A second run changes only what is still old.
- The result is small JSON a person reads: counts, keys and the failures.
- It ran in the sandbox, and Planned changes lists exactly the writes intended.
The console writes one for you
The Code Studio author, a Rovo agent, writes a script from a description with these rules, and the translator converts Groovy and ScriptRunner Connect scripts. See Rovo agents.
Related
- Requests to other systems
- Script API reference
- Script examples
- Script snippets
- Sandbox and production
- Limits
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.