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.
# docker compose / systemd environment
APP_JIRA_IMPORT_ENABLED=trueConnecting 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.
| authType | Use with | Credentials |
|---|---|---|
| anonymous | Public Jira instances that allow anonymous REST reads | None |
| basic | Jira Cloud | username = account email, secret = Atlassian API token |
| bearer | Jira Data Center / Server | secret = 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 (
CLOUDorDATA_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
| Endpoint | Purpose |
|---|---|
GET /api/v1/admin/jira-import/enabled | Feature probe; always mapped, returns {"enabled": bool} |
POST /api/v1/admin/jira-import/test-connection | Verifies the connection; returns edition, version and account display name |
POST /api/v1/admin/jira-import/projects | Lists the Jira projects visible to the supplied credentials |
POST /api/v1/admin/jira-import/run | Starts an asynchronous import; returns {"importId": "<uuid>"} |
GET /api/v1/admin/jira-import/status/{id} | Job status: state, counts, log; 404 for an unknown id |
# 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
| Jira | Iskue | Notes |
|---|---|---|
| Project (key, name, description) | Project | Key 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-123 | Ticket number 123 | Issue numbers are preserved, so PROJ-123 keeps its identity. The project's next_issue_number is advanced past the highest imported number. |
| Issue type | Ticket type (EPIC/STORY/TASK/BUG/SUBTASK) | Best-effort by name; see below. |
| Priority | The 5-level scale HIGHEST…LOWEST | Best-effort by name; see below. |
| Status | One workflow status per Jira status category | Only the category survives, not the status name; see below. |
| Reporter / assignee / comment authors | Users, auto-provisioned | See below. |
| Description and comments | Markdown text | ADF (Cloud) or wiki markup (Data Center) converted to Markdown; original comment authors and timestamps preserved. |
| Attachments | File storage + attachment records | Opt-in (includeAttachments). Bytes are downloaded from Jira into Iskue's file storage; individual failures are logged and skipped. |
| Labels | Project-scoped labels | One label per distinct string per project, colour #6b778c, names capped at 60 characters. Jira's global labels become per-project. |
| Created / updated timestamps | Preserved | Original 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, blocker | HIGHEST |
| high, critical | HIGH |
| lowest, trivial | LOWEST |
| low, minor | LOW |
| anything else (Medium, Major, Normal, custom, missing) | MEDIUM |
Statuses, users and rich text
- Statuses. Each issue maps through its Jira status category —
new,indeterminateordone— 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 theMEMBERrole, 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.
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
estimatefield on imported tickets is left empty. - Parent, epic and sub-task links — sub-tasks import with the
SUBTASKtype, 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.
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 Storymap toSTORY,Bug/DefecttoBUG,EpictoEPIC;Taskand sub-task variants — and anything unrecognised — map toTASK. - Priorities (exact match):
Highest/Blocker/Criticalmap toHIGHEST,High/MajortoHIGH,MediumtoMEDIUM,Low/MinortoLOW,Lowest/TrivialtoLOWEST; unrecognised values fall back to the default,MEDIUM. estimatemust 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.
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,createdExports 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.