Migrating from Jira

Iskue offers two routes out of Jira. The live importer connects directly to a Jira Cloud or Jira Data Center instance over REST and pulls projects, issues, comments and attachments into Iskue — admin-only, feature-flagged, and idempotent on re-run. The CSV route needs no connection: import a Jira CSV export into an existing project, or export Iskue tickets back out as CSV. This page covers both, including exactly what does not migrate.

The live Jira importer

The importer is a server-side job that talks to Jira's REST API. It auto-detects the Jira edition, walks issues project by project, and creates the corresponding Iskue entities. It runs asynchronously on a single background thread named jira-import; you start a job, receive an importId, and poll for progress. All importer endpoints require the ADMIN role.

Enable the feature flag

The importer is off by default. It is gated by the Spring property app.jira-import.enabled, bound to the environment variable APP_JIRA_IMPORT_ENABLED (default false). When the flag is off, the importer's controller and service beans are not created at all — the endpoints return 404 and the admin screen is hidden. One probe endpoint is always present regardless: GET /api/v1/admin/jira-import/enabled returns {"enabled": true|false} (admin-only); the SPA uses it to decide whether to show the Jira import link that leads to the /admin/jira-import screen.

Enable the live Jira importer, then restart the backend
# docker compose / systemd environment
APP_JIRA_IMPORT_ENABLED=true

Connecting to Jira Cloud or Data Center

Provide a base URL plus one of three authentication modes. On connect, the importer probes GET {baseUrl}/rest/api/2/serverInfo (available on both editions) and selects a client from the reported deploymentType: Cloud uses REST v3 (/rest/api/3) with token-paginated POST /search/jql and ADF rich text; Data Center / Server uses REST v2 (/rest/api/2) with offset-paginated search and wiki-markup rich text. You do not choose the edition yourself. Credentials are sent with the request that starts the job and held in memory for the duration of the run; they are not written to the database.

authTypeUse withCredentials
anonymousPublic Jira instances that allow anonymous REST readsNone
basicJira Cloudusername = account email, secret = Atlassian API token
bearerJira Data Center / Serversecret = Personal Access Token (the values pat and token are accepted as aliases for bearer)

Running an import from the admin screen

  • Step 1 — Connect. Enter the base URL, pick the auth mode, and use Test connection. A successful test reports the edition (CLOUD or DATA_CENTER), the Jira version, and the account you are connected as.
  • Step 2 — Pick projects. Load the project list from Jira and tick the projects to import (the list shows the first 200 matches of the filter). You can also type project keys directly into the filter box, comma-separated.
  • Step 3 — Options and run. Set the maximum issues per project (the form defaults to 50 and accepts up to 5,000) and optionally tick include attachments (off by default). Progress appears live: a counts table, the job state, and the last 40 log lines.

The REST endpoints

EndpointPurpose
GET /api/v1/admin/jira-import/enabledFeature probe; always mapped, returns {"enabled": bool}
POST /api/v1/admin/jira-import/test-connectionVerifies the connection; returns edition, version and account display name
POST /api/v1/admin/jira-import/projectsLists the Jira projects visible to the supplied credentials
POST /api/v1/admin/jira-import/runStarts an asynchronous import; returns {"importId": "<uuid>"}
GET /api/v1/admin/jira-import/status/{id}Job status: state, counts, log; 404 for an unknown id
Test, run, poll
# 1. Test the connection
curl -s -X POST "$ISKUE_URL/api/v1/admin/jira-import/test-connection" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "baseUrl": "https://your-site.atlassian.net",
    "authType": "basic",
    "username": "you@example.com",
    "secret": "<atlassian-api-token>"
  }'
# {"ok":true,"edition":"CLOUD","version":"...","accountDisplayName":"You","message":null}

# 2. Start the import
curl -s -X POST "$ISKUE_URL/api/v1/admin/jira-import/run" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "baseUrl": "https://jira.example.com",
    "authType": "bearer",
    "secret": "<personal-access-token>",
    "projectKeys": ["PROJ", "OPS"],
    "maxIssuesPerProject": 1000,
    "includeAttachments": true
  }'
# {"importId":"0b6c..."}

# 3. Poll for progress
curl -s "$ISKUE_URL/api/v1/admin/jira-import/status/0b6c..." \
  -H "Authorization: Bearer $ADMIN_TOKEN"

The run request also accepts an optional jql string that replaces the default per-project query project = "KEY" ORDER BY created ASC. When maxIssuesPerProject is omitted, the server defaults to 500 issues per project; issues are fetched in pages of at most 50. Job state is RUNNING, COMPLETED or FAILED; the counts map has the keys projects, issues, comments, attachments, users, labels, skipped and errors, and the log keeps at most 500 lines. Job status lives in memory only — a backend restart loses the status view (not the imported data). A project that fails is logged, counted as an error, and the import continues with the next project; there is no automatic retry or rate-limit backoff.

What maps, and how

JiraIskueNotes
Project (key, name, description)ProjectKey is uppercased, stripped to A–Z0–9, capped at 10 characters. An existing project with the same key is reused; a new project is created with the default Scrum workflow (To Do / In Progress / Done).
Issue key PROJ-123Ticket number 123Issue numbers are preserved, so PROJ-123 keeps its identity. The project's next_issue_number is advanced past the highest imported number.
Issue typeTicket type (EPIC/STORY/TASK/BUG/SUBTASK)Best-effort by name; see below.
PriorityThe 5-level scale HIGHEST…LOWESTBest-effort by name; see below.
StatusOne workflow status per Jira status categoryOnly the category survives, not the status name; see below.
Reporter / assignee / comment authorsUsers, auto-provisionedSee below.
Description and commentsMarkdown textADF (Cloud) or wiki markup (Data Center) converted to Markdown; original comment authors and timestamps preserved.
AttachmentsFile storage + attachment recordsOpt-in (includeAttachments). Bytes are downloaded from Jira into Iskue's file storage; individual failures are logged and skipped.
LabelsProject-scoped labelsOne label per distinct string per project, colour #6b778c, names capped at 60 characters. Jira's global labels become per-project.
Created / updated timestampsPreservedOriginal Jira timestamps are written onto imported tickets and comments.

Issue types and priorities

Jira's sub-task flag maps to SUBTASK. Otherwise the type name is matched case-insensitively: names containing *epic* map to EPIC; *story* or *feature* (e.g. "New Feature") to STORY; *bug* or *defect* to BUG; *sub-task*/*subtask* to SUBTASK; everything else — Task, Improvement, Wish, Test, custom types — falls back to TASK. The resulting code is resolved against the target project's ticket-type catalogue; if the code has no matching type there, the importer falls back to the project's TASK type. Each unmapped Jira type name is logged once in the job log so you can review the fall-throughs. Priorities map by substring, in order:

Jira priority name (contains)Iskue priority
highest, blockerHIGHEST
high, criticalHIGH
lowest, trivialLOWEST
low, minorLOW
anything else (Medium, Major, Normal, custom, missing)MEDIUM

Statuses, users and rich text

  • Statuses. Each issue maps through its Jira status category — new, indeterminate or done — onto the first Iskue workflow status in the matching category (TODO, IN_PROGRESS, DONE). Individual Jira status names such as "In Review" or "Blocked" are not recreated: an issue in any in-progress status lands in the project's single In Progress column, and Jira workflow definitions, transitions, conditions and validators are not imported.
  • Users. Reporters, assignees, comment authors and attachment uploaders are auto-provisioned. When Jira returns an email address, the importer matches or creates an Iskue user by that email; when it does not (common on Jira Cloud, where emails are private), it creates a placeholder with a synthetic address of the form <jira-identity>@imported.jira.local. Provisioned users get the MEMBER role, join the default organisation, and have no password — they cannot sign in until an admin sets them up. Actors that cannot be resolved fall back to the admin who started the import.
  • Rich text. Descriptions and comment bodies are normalised to Markdown. Cloud's ADF documents convert node-by-node (paragraphs, headings, lists, code blocks, quotes, links, marks, mentions); media nodes degrade to an _[media]_ placeholder, and panels and tables flatten to their text content. Data Center's wiki markup converts headings, bold/italic/monospace, {code}, {noformat} and {quote} blocks, links and lists; unrecognised constructs are left as readable text. The conversion is deliberately lossy — it never fails an import over formatting.

Idempotent re-runs

Every imported entity is recorded in the jira_import_mapping table, keyed by source instance, entity type and Jira id. On a re-run, an already-mapped issue is skipped (counted under skipped), while missing comments and attachments for that issue are filled in — so an interrupted run can simply be started again with the same parameters, and running the same import twice never duplicates projects, tickets or comments.

The idempotency map (Flyway migration V25__jira_import_mapping.sql)
CREATE TABLE jira_import_mapping (
    source_base_url VARCHAR(500) NOT NULL,
    entity_type     VARCHAR(30)  NOT NULL,   -- project | user | issue | comment | attachment | label
    jira_id         VARCHAR(255) NOT NULL,
    jira_key        VARCHAR(255),
    issuehub_id     UUID         NOT NULL,
    content_hash    VARCHAR(64),
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (source_base_url, entity_type, jira_id)
);

Re-runs skip; they do not update. An issue edited in Jira after the first run keeps its originally imported title, description, status and priority in Iskue (the content_hash column exists for future change detection but is not used yet). Treat the importer as a one-way migration, not a continuous sync.

What does not migrate

The importer fetches a fixed field set per issue — summary, description, issuetype, status, priority, reporter, assignee, labels, created, updated, parent, attachment — and nothing else. Concretely, the following stay behind:

  • Sprints and boards — no sprint or board data is read.
  • Custom fields and their values — including story-point estimates; the estimate field on imported tickets is left empty.
  • Parent, epic and sub-task links — sub-tasks import with the SUBTASK type, but the parent relationship is not set; the hierarchy is flattened.
  • Issue links (blocks / relates / duplicates), components, fix versions and affects versions.
  • Worklogs, time tracking, watchers and votes.
  • Issue change history — only the current state of each issue is imported.
  • Workflow definitions — status names, transitions, conditions and validators (statuses collapse to the three category buckets).
  • Project configuration — permission schemes, notification schemes, screens, automation rules.
  • User credentials, avatars and groups — imported users cannot sign in.

The CSV alternative

When a live connection is not possible, use CSV. This route is per-project, does not require the feature flag, and is available to project members rather than only admins.

Importing a Jira CSV export

Send the CSV text to POST /api/v1/projects/{projectKey}/import/csv as a JSON body {"csv": "..."}, or use the Import CSV button on the project page, which uploads a file for you. The header row decides the column mapping and only title is required; Jira's export headers are recognised as aliases, case-insensitively: Summary maps to title, Issue Type to type, and Story Points (or Custom field (Story Points)) to estimate. The parser is minimal RFC 4180 — quoted cells, "" escapes, and newlines inside quotes are all handled.

Import two rows from a Jira-style CSV
curl -s -X POST "$ISKUE_URL/api/v1/projects/PROJ/import/csv" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"csv":"Summary,Issue Type,Priority,Story Points,Labels\nFix login redirect,Bug,High,3,auth|frontend\nOnboarding flow,Story,Medium,5,ux"}'
# {"imported":2,"errors":[]}
  • Supported columns: title, type, priority, description, estimate, labels (pipe-separated, e.g. auth|frontend).
  • At most 1,000 data rows per request; larger files are rejected with a 400 before any row is processed. Split bigger exports into multiple requests.
  • Each row is created in its own transaction, so one bad row does not roll back the rest; the response lists per-line errors as {"line": n, "message": "..."}.
  • Types: Story/User Story map to STORY, Bug/Defect to BUG, Epic to EPIC; Task and sub-task variants — and anything unrecognised — map to TASK.
  • Priorities (exact match): Highest/Blocker/Critical map to HIGHEST, High/Major to HIGH, Medium to MEDIUM, Low/Minor to LOW, Lowest/Trivial to LOWEST; unrecognised values fall back to the default, MEDIUM.
  • estimate must be an integer from 0 to 1,000; anything else fails that row.

CSV import carries less than the live importer: no status (every ticket starts at the project's initial status), no assignee or original reporter (tickets are created as the importing user), no comments, no attachments, no original timestamps (creation is stamped at import time), and no issue numbers — new keys are assigned in sequence.

Exporting tickets to CSV

GET /api/v1/projects/{projectKey}/tickets.csv returns the project's tickets as a CSV download named <projectkey>-tickets.csv. It requires project membership and always emits the same 10 columns: key, type, title, status, priority, assignee, reporter, estimate, labels, created. Descriptions and comments are not included. Tickets are ordered by issue number ascending, fetched in pages of 200 with a hard limit of 50 pages — an export is therefore capped at 10,000 tickets, and anything beyond that is silently omitted.

Export a project's tickets
curl -s "$ISKUE_URL/api/v1/projects/PROJ/tickets.csv" \
  -H "Authorization: Bearer $TOKEN" -o proj-tickets.csv

head -1 proj-tickets.csv
# key,type,title,status,priority,assignee,reporter,estimate,labels,created

Exports round-trip: the export's title, type, priority, estimate and labels columns are exactly what the CSV importer understands, so you can export from one Iskue project and import into another. The key, status, assignee, reporter and created columns are ignored on import.