# Writing scripts

> How to write an Oktul Code Studio script: what each trigger hands it, reading and writing Jira and Assets, the sandbox, long runs and results.
>
> Documentation for Oktul Code Studio, for Jira Cloud and Confluence Cloud, by Oktul: https://oktul.com/docs/plugins/code-studio/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](../script-api/), and whole scripts are in
[script examples](../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.

```ts
export default async function (event: unknown, context: ScriptContext) {
    log.info(`Mode: ${context.mode}`);
    return { updated: 0 };
}
```

- **`event`** is what started the run. Its shape depends on the trigger, as the next section lists.
- **`context`** carries 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 `await` works.
- **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: no `fs`, no `Buffer`, no `process`.

## 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](../script-examples/#raise-the-number-at-the-end-of-a-summary) does:

```ts
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](../script-examples/) does.
- **What differs between sites, or must stay out of the code,** goes in
  [script variables](../connections-and-variables/#script-variables), read as
  `context.environment.vars.NAME`. Each is a string or `undefined`.

```ts
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.

```ts
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 `HttpError` on a status of 400 or more, with
  `status`, `headers` and `body`. A 204 answers `null`.
- **`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:

  ```ts
  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.body` is 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:

```ts
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/field` once and keep a `Map`.
- A transition is `GET …/transitions`, then `POST …/transitions` with the id of the one whose
  `to.name` matches.

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:

```ts
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:

```ts
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:

```ts
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.com` for a token to call
  `api.atlassian.com/ex/jira/<cloud id>/…` is calling this site: use `jira` or `assets` with 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](../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:

  ```ts
  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-123` lets a person try one
  production edit and look at it in Jira before the whole run.
- **`store` keeps 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](../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 a `Map`.
- **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.all` over hundreds:

  ```ts
  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()`, read `retry-after` in seconds, and wait 2, 4,
  8, 16 and 30 seconds otherwise, at most six attempts:

  ```ts
  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, with `context.resumedFrom` holding the state. So a script that checkpoints
  either starts from `context.resumedFrom`, or is safe to run again from the top because it writes
  only what is still old:

  ```ts
  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.warn` and `log.error` write the run's log, and `console.*` 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.warn` and 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

1. Settings at the top, named for what they hold; secrets in a connection, never in the code.
2. Missing settings refused before any request.
3. Searches ask for only the fields read, and every list is read to its last page.
4. No write depends on a write's response; the first production write is read back.
5. Requests in parallel at most a few at a time, with retries on 429 and 5xx.
6. `context.checkpoint` in any loop over hundreds of items.
7. Every item not changed is reported with its reason, in the log and the result.
8. A second run changes only what is still old.
9. The result is small JSON a person reads: counts, keys and the failures.
10. It ran in the sandbox, and Planned changes lists exactly the writes intended.

<Aside type="note" title="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](../rovo/).
</Aside>

## Related

- [Requests to other systems](../requests-to-other-systems/)
- [Script API reference](../script-api/)
- [Script examples](../script-examples/)
- [Script snippets](../snippets/starting-points/)
- [Sandbox and production](../sandbox-and-production/)
- [Limits](../limits/)

---

A problem or a question? Write to [support@oktul.com](mailto:support@oktul.com) or open a request in the [Help Center](https://oktul.atlassian.net/servicedesk/customer/portals). Both open a request with a reference and an SLA.
