# Create tickets from a script

Turn a list of work into tickets on a team, in a project and milestone, each with its Scope, without creating duplicates.

This example reads a list of work from a JSON file and creates a ticket on the Web team for each
item, in the `docs-launch` project, and writes each ticket's Scope. It skips items that already have
a ticket with the same title, so you can run it again safely. It uses TypeScript with Node's built-in `fetch`; any language that
speaks HTTP works the same way.

## Before you begin

- An API key in `CLANKER_API_KEY`: your own, or an account API key with the member role. See
  [Authentication](https://docs.clankership.com/api/authentication.md).
- The team (`WEB`) and the project (`docs-launch`), with the team linked to the project. A ticket
  joins a project only when its team is linked to it.
- Tag names that exist for the team. [List tags](https://docs.clankership.com/api/tags/list.md) with `team=WEB` shows them.
- The team's default status keeps the Scope document, as Backlog does in every new workspace. See
  [Documents](https://docs.clankership.com/get-started/concepts/planning/tickets.md#documents).

## The input

```json
[
  {
    "title": "Add docs search",
    "scope": "## Goal\n\nReaders find any page from the docs header.\n\n## Background\n\nReaders cannot find a page without browsing the sidebar.",
    "milestone": "M2",
    "tags": ["feature"]
  },
  {
    "title": "Fix broken links in the API reference",
    "scope": "## Goal\n\nEvery link in the API reference opens its page.\n\n## Background\n\nLinks from the Tickets page to the Ticket object return 404.",
    "tags": ["bug"]
  }
]
```

Save it as `tickets.json`. `scope` is the ticket's Scope in Markdown, up to 200,000 characters, and
`milestone` names one of the project's milestones, such as `M2`.

## The script

```typescript
// create-tickets.mts: npx tsx create-tickets.mts tickets.json
import { readFile } from "node:fs/promises";

const API = "https://api.clankership.com/v1/workspaces/acme";
const TEAM = "WEB";
const PROJECT = "docs-launch";
const headers = {
  Authorization: `Bearer ${process.env.CLANKER_API_KEY}`,
  "Content-Type": "application/json",
};

type Item = { title: string; scope?: string; milestone?: string; tags?: string[] };

async function call<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`${API}${path}`, { headers, ...init });
  const body = await response.json();
  // Errors come back as { _tag, message }, such as { _tag: "InvalidInput", message: "…" }.
  if (!response.ok) throw new Error(`${response.status} ${body._tag}: ${body.message}`);
  return body as T;
}

const items: Item[] = JSON.parse(await readFile(process.argv[2]!, "utf8"));

// The project's tickets on the team, so a second run skips what the first one created.
const { tickets } = await call<{ tickets: { identifier: string; title: string }[] }>(
  `/tickets?team=${TEAM}&project=${PROJECT}`,
);
const existing = new Map(tickets.map((ticket) => [ticket.title, ticket.identifier]));

for (const item of items) {
  const found = existing.get(item.title);
  if (found) {
    console.log(`${found} already exists: ${item.title}`);
    continue;
  }
  const { scope, ...fields } = item;
  const ticket = await call<{ identifier: string }>("/tickets", {
    method: "POST",
    body: JSON.stringify({ team: TEAM, project: PROJECT, ...fields }),
  });
  // A new ticket's Scope is at revision 0, its template, until the first write.
  if (scope)
    await call(`/tickets/${ticket.identifier}/documents/scope`, {
      method: "PUT",
      body: JSON.stringify({ body: scope, baseRevision: 0 }),
    });
  console.log(`${ticket.identifier} created: ${item.title}`);
}
```

Run it:

```bash
export CLANKER_API_KEY="ctk_…"
npx tsx create-tickets.mts tickets.json
```

```text
WEB-31 created: Add docs search
WEB-32 created: Fix broken links in the API reference
```

The `.mts` extension makes the file an ES module, so it can use top-level `await`.

## How it behaves

- [Create a ticket](https://docs.clankership.com/api/tickets/create.md) gives each ticket the team's next number and puts it at
  the end of the workspace's ticket order. Numbers are never reused.
- A ticket gets the team's default status unless you send `status`, by name, such as `"Plan"`. The
  defaults in the team's [clanker instructions](https://docs.clankership.com/get-started/concepts/planning/clanker-instructions.md)
  apply only to tickets that clankers create.
- Creating a ticket is not idempotent: sending the same request twice creates two tickets. That is
  why the script lists the project's tickets first. See
  [Idempotent requests](https://docs.clankership.com/api/idempotent-requests.md).
- [Write a ticket document](https://docs.clankership.com/api/ticket-documents/write.md) with `baseRevision: 0` writes the first
  revision of the Scope. Once anyone has written it, a write from revision 0 fails with
  `409 Conflict`, so the script never replaces a Scope someone changed.
- A bad item fails with a message that names the problem: `400 InvalidInput` when the team is not
  linked to the project, or `404 NotFound` for a tag or milestone that does not exist. The script
  stops at the first error, keeping the tickets it already created.

## Variations

- **Give each ticket an owner and clankers.** Send `owner` with a member's email, and `clankers`
  with clanker IDs from [List clankers](https://docs.clankership.com/api/clankers/list.md).
- **Send the new tickets to a clanker.** [Send a message](https://docs.clankership.com/api/messages/send.md) with `ask: "draft"`
  asks the clanker to write each Scope from the status's instructions.
- **One ticket from the shell.** The CLI does the same for one item:

  ```bash
  clanker ticket create --team WEB --title "Add docs search" --project docs-launch \
    --milestone M2 --tag feature
  clanker ticket doc write WEB-31 scope --file scope.md
  ```
