# migration-doctor-snapshot Free, open-source, **read-only** snapshot of Jira Data Center configuration, for the Migration Doctor app on Jira Cloud by Nox Development. You run one script on a machine that can reach your Jira Data Center. It reads configuration through the Jira REST API (HTTP GET only) and writes one JSON file. You then upload that file into Migration Doctor on your own Jira Cloud site, which compares it with the migrated configuration and reports what JCMA left broken: filters that reference DC usernames, filters and dashboards that did not arrive, empty workflow conditions and validators, permission schemes that point at missing groups. Two equivalent editions, same output: | Script | Runs on | Needs | | --- | --- | --- | | `snapshot.ps1` | Windows PowerShell 5.1, PowerShell 7+ (Windows, Linux, macOS) | nothing beyond built-in cmdlets and .NET | | `snapshot.sh` | bash 3.2+ (Linux, macOS, Git Bash, WSL) | `curl`, `jq` 1.6+ | The snapshot is useful on its own as a record of the DC configuration before a migration. ## Contents - [What it collects and what it never collects](#what-it-collects-and-what-it-never-collects) - [Privacy](#privacy) - [Requirements](#requirements) - [Usage](#usage) - [Read-only SQL exports (filters, dashboards, workflow rules)](#read-only-sql-exports) - [Output format](#output-format) - [REST endpoints used](#rest-endpoints-used) - [Jira versions: 8.20, 9.x, 10.x, 11.x](#jira-versions) - [Troubleshooting](#troubleshooting) - [Development and tests](#development-and-tests) ## What it collects and what it never collects **Collects (configuration only):** | Section | Fields | Source | | --- | --- | --- | | Server | Jira version, base URL, server title | REST | | Users | username, user key, display name, e-mail, active flag (active **and** inactive users) | REST (SQL adds users REST cannot list and e-mails hidden by e-mail visibility) | | Groups | group names | REST (capped, see below) or SQL (complete) | | Projects | id, key, name, project type (archived projects included) | REST | | Fields | id, name, custom flag, custom field type key | REST | | Statuses | id, name | REST | | Permission schemes | grants (permission + holder type and parameter), projects using the scheme | REST | | Workflows | names; with SQL: transitions (from, to), conditions, validators, post functions, providing app | REST + SQL | | Boards (optional) | id, name, type, board filter id | REST (Jira Software agile API) | | Filters | id, name, description, JQL, owner username, share permissions | REST (only filters this account can see) or SQL (all, including private) | | Dashboards | id, name; with SQL: owner, share permissions, gadgets with their filter | REST + SQL | **Never collects:** issues, issue fields or values, comments, attachments, worklogs, issue history, passwords, tokens, API keys, avatars, application properties, licences, audit logs, or anything from other Atlassian products. **Never does:** any request other than HTTP GET; any change to Jira; any network call to anything other than the Jira base URL you give it. It does not follow HTTP redirects, so credentials are never sent to another host. It does not run SQL: the optional SQL queries are run by you, with your own database client and a read-only database account. ## Privacy The snapshot file contains **personal data**: usernames, display names and e-mail addresses of all users (active and inactive), plus filter names and JQL, which can contain names of people and projects. - The file stays on the machine where you run the script. The script sends it nowhere. - Keep it on that machine until you upload it into the Migration Doctor app **on your own Jira Cloud site**. Migration Doctor runs on Atlassian (Forge, no egress): the snapshot is stored inside Atlassian's platform for your site and is not sent to Nox Development or any third party. - In the app you can delete an uploaded snapshot at any time. Delete the local file when the migration review is finished. - On Linux and macOS the bash edition creates the file readable by your user only (mode 600). - Credentials are read from environment variables, are sent only in the `Authorization` header to your Jira, and are never written to the output, the console or a log. The bash edition passes them to curl on standard input, so they do not appear in the process list. ## Requirements - A Jira Data Center (or Server) instance, version 8.20 to 11.x. 9.x is the primary target. - A **Jira System Administrator** account. Workflows, permission schemes and the user list need admin rights. - A personal access token (recommended, Jira 8.14+): profile picture, **Profile**, **Personal Access Tokens**, **Create token**. Give it a short expiry and revoke it after the snapshot. Basic authentication with username and password also works. - Network access from where you run the script to the Jira base URL. - PowerShell edition: Windows PowerShell 5.1 or PowerShell 7+. Nothing to install. - bash edition: bash 3.2+, curl, jq 1.6+ (`apt install jq`, `dnf install jq`, `brew install jq`, `winget install jqlang.jq`). ## Usage Credentials come **only** from environment variables. The scripts reject any secret passed as an argument. | Variable | Meaning | | --- | --- | | `JIRA_PAT` | personal access token, sent as `Authorization: Bearer` (preferred) | | `JIRA_USER`, `JIRA_PASSWORD` | basic authentication, used when `JIRA_PAT` is not set | ### PowerShell ```powershell # PowerShell 7: prompt without echo $env:JIRA_PAT = Read-Host 'Jira personal access token' -MaskInput # Windows PowerShell 5.1: prompt without echo $env:JIRA_PAT = [System.Net.NetworkCredential]::new('', (Read-Host 'Jira personal access token' -AsSecureString)).Password .\snapshot.ps1 -BaseUrl https://jira.example.com .\snapshot.ps1 -BaseUrl https://example.com/jira -Out C:\temp\acme-snapshot.json -Projects ACME,OPS .\snapshot.ps1 -BaseUrl https://jira.example.com -SqlExportDir .\export Remove-Item Env:\JIRA_PAT ``` If the execution policy blocks the script: `powershell -ExecutionPolicy Bypass -File .\snapshot.ps1 -BaseUrl ...`, or `Unblock-File .\snapshot.ps1` after downloading. | Parameter | Default | Meaning | | --- | --- | --- | | `-BaseUrl` | required | Jira base URL including the context path, e.g. `https://example.com/jira` | | `-Out` | `migration-doctor-snapshot-.json` | output file | | `-Projects` | all | limit projects, their permission schemes, workflows and boards to these keys | | `-SqlExportDir` | none | directory with the results of the [SQL exports](#read-only-sql-exports) | | `-FilterScanGap` | `100` | without SQL exports, probe filter IDs (see [Filters](#filters-without-sql)); `0` disables | | `-DelayMs` | `100` | pause between requests | | `-MaxRetries` | `5` | retries for HTTP 429, 502, 503, 504 and network errors | | `-NoBoards` | off | skip Jira Software boards | ### bash ```bash read -rsp 'Jira personal access token: ' JIRA_PAT && export JIRA_PAT && echo ./snapshot.sh --base-url https://jira.example.com ./snapshot.sh --base-url https://example.com/jira --out /tmp/acme-snapshot.json --projects ACME,OPS ./snapshot.sh --base-url https://jira.example.com --sql-export-dir ./export unset JIRA_PAT ``` Options are the same as in PowerShell: `--base-url`, `--out`, `--projects`, `--sql-export-dir`, `--filter-scan-gap`, `--delay-ms`, `--max-retries`, `--no-boards`, `--help`, `--version`. ### What you see Progress goes to the console (stderr), one step per section, then a summary: ``` Snapshot written: migration-doctor-snapshot-2026-10-11.json (16353 bytes, 20 GET requests, 14s) users 6 groups 6 projects 4 ... Coverage: users=full, groups=full, ..., filters=full, dashboards=full, workflows=full, ... ``` Exit codes: `0` success (warnings possible), `1` the run failed (HTTP error after retries, authentication, permissions, invalid SQL export), `2` usage error. ### `--projects` Limits projects, the permission schemes used by those projects, the workflows in their workflow schemes, and their boards. Users, groups, fields, statuses, filters and dashboards are global in Jira and are always exported in full, because filters and dashboards often span projects. ## Read-only SQL exports Jira Data Center's REST API cannot provide three things Migration Doctor checks: | Gap in DC REST (8.20 to 11.x) | Effect without SQL | SQL export | | --- | --- | --- | | No endpoint lists all filters; an admin cannot read other users' private filters | only filters this account can see (`coverage.filters = rest-partial`) | `filters.sql` + `share-permissions.sql` | | Dashboards REST returns only id and name | no owner, sharing or gadgets (`coverage.dashboards = rest-no-owner`) | `dashboards.sql` + `dashboard-gadgets.sql` + `share-permissions.sql` | | No endpoint exposes workflow transitions, conditions, validators or post functions | workflows with empty `transitions` (`coverage.workflows = none`) | `workflows.sql` (+ `plugins.sql`) | | The group list is capped by `jira.ajax.autocomplete.limit` | possibly incomplete (`coverage.groups = rest-partial`) | `groups.sql` | | User listing differs by version and hides e-mails under some e-mail visibility settings | e-mails may be missing | `users.sql` | So the SQL exports are a normal part of a complete snapshot, not an emergency fallback. They are plain `SELECT` statements in [`sql/`](sql), one file per section and database: | Database | Directory | Client | Tested in CI on | | --- | --- | --- | --- | | PostgreSQL | [`sql/postgresql`](sql/postgresql) | `psql` | PostgreSQL 16 | | MySQL 5.7.8+ / 8.x | [`sql/mysql`](sql/mysql) | `mysql` | MySQL 8.0 | | Microsoft SQL Server | [`sql/sqlserver`](sql/sqlserver) | `sqlcmd` | SQL Server 2022 | | Oracle | not provided yet | | | **You run them, not the script.** Use a read-only database account (or a read replica). Each query returns one JSON object per line; save each result as `.jsonl` in one directory and pass that directory with `--sql-export-dir` / `-SqlExportDir`. The script reads only these file names: `users`, `groups`, `filters`, `share-permissions`, `dashboards`, `dashboard-gadgets`, `workflows`, `plugins` (each `.jsonl`). All are optional, but `filters` and `dashboards` need `share-permissions` from the same run, and `dashboards` needs `dashboard-gadgets`. PostgreSQL: ```bash mkdir -p export for f in sql/postgresql/*.sql; do psql -X -q -A -t -v ON_ERROR_STOP=1 -h DBHOST -U readonly -d jiradb -f "$f" -o "export/$(basename "$f" .sql).jsonl" done # Tables in a non-default schema: export PGOPTIONS='-c search_path=your_schema' first. ``` MySQL: ```bash mkdir -p export for f in sql/mysql/*.sql; do mysql --batch --raw --skip-column-names --default-character-set=utf8mb4 -h DBHOST -u readonly -p"$DBPASS" jiradb < "$f" > "export/$(basename "$f" .sql).jsonl" done ``` SQL Server (PowerShell; `JIRA_SCHEMA` is the schema of the Jira tables, usually `jiraschema`; `-y 0` stops sqlcmd from truncating long values such as workflow descriptors): ```powershell New-Item -ItemType Directory -Force export | Out-Null Get-ChildItem sql\sqlserver\*.sql | ForEach-Object { sqlcmd -S DBHOST -d jiradb -E -v JIRA_SCHEMA=jiraschema -y 0 -f 65001 -i $_.FullName -o "export\$($_.BaseName).jsonl" } ``` In SQL Server Management Studio, enable **Query, SQLCMD Mode** before running a file (the files use the `$(JIRA_SCHEMA)` sqlcmd variable), and save results to a file; the sqlcmd loop above is simpler and does not truncate long values. Notes: - Lines that are not JSON objects (headers, row counts, blank lines) are ignored, so minor client output differences do not matter. A UTF-8 BOM is accepted. - Owners and shared-with users are resolved from user keys (`JIRAUSER10100`) to usernames through `app_user` and the first active user directory, like Jira does. A deleted user's filter keeps the lower-case username from `app_user`, or the raw key if even that is gone. - The system workflow `jira` is built into Jira and is not stored in the database, so it has no transitions in the snapshot (`transitionsAvailable: false`). It cannot be edited in DC either. - `plugins.sql` lists installed apps (`pluginversion`). It lets the script attribute a workflow rule to the app that provides it (`pluginKey`, e.g. ScriptRunner or JSU) instead of guessing. - The workflow descriptor XML is parsed by the script (PowerShell: .NET XML; bash: a small XML tokenizer in jq). Each transition lists its source statuses (`from`), target status (`to`), and every condition (including nested condition groups), validator and post function with its implementation class, module key and providing app. Hidden built-in post functions (create issue, fire event, reindex) are included as they are part of the transition in DC. ### Filters without SQL Without `filters.jsonl` the script collects your favourite filters, the filters of the boards it can read, and probes filter IDs upwards from 10000 (`GET /rest/api/2/filter/{id}`) until it sees `--filter-scan-gap` consecutive IDs that are missing or not visible, after the highest ID it already knows. This finds every filter **shared with or owned by the account** running the script, including other users' filters shared with a group or project you belong to. Other users' **private** filters are never visible through REST, not even to a system administrator: use the SQL export for them. ## Output format One UTF-8 JSON file that validates against [`schema/snapshot.schema.json`](schema/snapshot.schema.json) (JSON Schema 2020-12, a copy of the schema the Migration Doctor app validates uploads with): ```json { "format": "migration-doctor-snapshot", "schemaVersion": 1, "createdAt": "2026-10-11T09:00:00Z", "generator": { "name": "migration-doctor-snapshot", "version": "1.0.0", "runtime": "bash" }, "source": { "product": "jira", "deployment": "datacenter", "version": "9.12.3", "baseUrl": "https://jira.example.com", "serverTitle": "Acme Jira" }, "coverage": { "users": "full", "groups": "full", "filters": "rest-partial", "dashboards": "rest-no-owner", "workflows": "none", "...": "..." }, "users": [], "groups": [], "projects": [], "fields": [], "statuses": [], "filters": [], "dashboards": [], "workflows": [], "permissionSchemes": [], "boards": [] } ``` `coverage` tells the app which sections are complete (`full`), partial because of REST limits (`rest-partial`), missing owners (`rest-no-owner`) or missing entirely (`none`), so it can avoid false alarms. Arrays are sorted (by name, key or numeric id) so two snapshots of the same instance diff cleanly. `source.deployment` is always `datacenter`: `serverInfo.deploymentType` reports `Server` on every self-managed instance, and Server licences ended in February 2024. Fields beyond the schema's documented properties (allowed by it) that the script adds: `workflows[].transitionsAvailable` (false when no descriptor was available), `transitions[].looped` (true for a transition that returns to the status it starts from; `to` is then empty), `rules[].moduleKey` (the `full.module.key` from the descriptor) and `generator.runtime`. ## REST endpoints used All requests are `GET`, sequential, with a pause between them (`--delay-ms`), and retried with exponential backoff on HTTP 429 (honouring `Retry-After`), 502, 503, 504 and network errors. | Purpose | Endpoint | Paging | Reference | | --- | --- | --- | --- | | Version, base URL, title | `/rest/api/2/serverInfo` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/serverInfo-getServerInfo) | | Users (10.3+, 11.x) | `/rest/api/2/user/list?maxResults=1000&cursor=` | cursor (`nextCursor`, `isLast`) | [11.x user group](https://developer.atlassian.com/server/jira/platform/rest/v11002/api-group-user/) (also in the 10.3 spec) | | Users (8.20 to 10.2) | `/rest/api/2/user/search?username=.&includeActive=true&includeInactive=true&startAt=&maxResults=1000` | `startAt` by rows returned, until an empty page | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/user-findUsers) | | Groups | `/rest/api/2/groups/picker?query=&maxResults=100000` | none (capped, `total` checked) | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/groups-findGroups) | | Projects | `/rest/api/2/project?includeArchived=true` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/project-getAllProjects) | | Fields | `/rest/api/2/field` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/field-getFields) | | Statuses | `/rest/api/2/status` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/status-getStatuses) | | Permission schemes with grants | `/rest/api/2/permissionscheme?expand=permissions,user,group,projectRole,field` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/permissionscheme-getPermissionSchemes) | | Scheme of each project | `/rest/api/2/project/{key}/permissionscheme` | one call per project | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/project/{projectKeyOrId}/permissionscheme-getAssignedPermissionScheme) | | Workflow names | `/rest/api/2/workflow` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/workflow-getAllWorkflows) | | Workflows of a project (`--projects` only) | `/rest/api/2/project/{key}/workflowscheme` | one call per project | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/project/{projectKeyOrId}/workflowscheme-getWorkflowSchemeForProject) | | Boards | `/rest/agile/1.0/board?startAt=&maxResults=50` (`&projectKeyOrId=` with `--projects`) | `startAt`, `isLast` | [Jira Software 9.12](https://docs.atlassian.com/jira-software/REST/9.12.0/#agile/1.0/board-getAllBoards) | | Board filter | `/rest/agile/1.0/board/{id}/configuration` | one call per board | [Jira Software 9.12](https://docs.atlassian.com/jira-software/REST/9.12.0/#agile/1.0/board-getConfiguration) | | Favourite filters | `/rest/api/2/filter/favourite` | none | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/filter-getFavouriteFilters) | | Filter by id (without SQL) | `/rest/api/2/filter/{id}` | ID probing | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/filter-getFilter) | | Dashboards (without SQL) | `/rest/api/2/dashboard?startAt=&maxResults=` | `startAt` in multiples of the returned `maxResults`, until `total` | [9.12](https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/#api/2/dashboard-list) | Request count for a typical instance: about 15 + one per project (two with `--projects`) + one per board + the filter probing range when SQL exports are not used. Endpoints deliberately **not** used: `/rest/api/2/filter/search` (does not exist on DC), undocumented internal endpoints such as the workflow designer REST resource, the workflow XML export page (`/secure/admin/workflows/ViewWorkflowXml.jspa`, a web action that can require websudo), and the Universal Plugin Manager REST API. ## Jira versions | | 8.20 | 9.x (primary) | 10.x | 11.x | | --- | --- | --- | --- | --- | | Personal access tokens | yes (8.14+) | yes | yes | yes | | Users | `user/search`; **8.20.0 to 8.20.6 return at most 100 rows per call** even when 1000 are requested (fixed in 8.20.7). The script pages by rows actually returned, so it still gets everyone | `user/search`, 1000 per call | 10.0 to 10.2: `user/search`, 1000 per call. 10.3+ adds `user/list`, which the script prefers | `user/list` (cursor). `user/search` reaches only the first 100 users | | Agile boards | `/rest/agile/1.0` | same | same (Jira Software and Jira Core REST published together from 10.0) | same | | Filters, dashboards, workflow rules via REST | not available | not available | not available | not available | | Database queries | same tables | same | same | same | The script detects `user/list` by calling it first and falls back to `user/search` on HTTP 404. If a 10.x or 11.x instance without `user/list` returns exactly 100 users through `user/search`, the script warns and you should add `users.jsonl` from SQL. `username=.` as a match-all query is the long-standing community convention for listing users on DC; it is not formally documented by Atlassian. Jira 8.20 reached end of support; 9.x and later are the realistic migration sources. The script was tested against recorded 9.12 and 11.0 responses (see [tests](#development-and-tests)), not against live 8.20 or 10.x servers; reports from real instances are welcome. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `HTTP 401 ... Authentication failed` | Wrong or expired token, or `JIRA_PAT` not exported. With basic auth, check `JIRA_USER`/`JIRA_PASSWORD`. | | `HTTP 403` on the first calls | Basic auth hit the login CAPTCHA after failed logins: log in once in the browser, or use a personal access token. Also check that the account is a Jira System Administrator. | | `HTTP 401` or `403` on workflows or permission schemes only | The account is not a system administrator. Some instances also require a websudo (secure administrator) session for admin REST resources; a personal access token normally bypasses it. | | `HTTP 404` on `serverInfo` | Wrong base URL: include the context path, e.g. `https://example.com/jira`, not just the host. | | `HTTP 3xx ... redirected` | The base URL redirects (http to https, or a different host or path). Use the final URL. The script never follows redirects, to keep credentials on the host you named. | | `response that is not JSON` | A proxy, SSO page or load balancer answered instead of Jira. Run from a machine inside the network, or bypass SSO for REST with a personal access token. | | TLS certificate errors | bash: point curl at your CA bundle with `CURL_CA_BUNDLE=/path/to/ca.pem`. PowerShell: import the CA into the Windows certificate store. The scripts never disable certificate checks. | | Many `HTTP 429` retries | Jira rate limiting for REST is on. Increase `--delay-ms` (e.g. 500) or ask the Jira admin to exempt the account during the snapshot. | | Filter probing takes long | Probing makes one request per ID. Use the SQL export `filters.sql`, which also includes private filters, or lower `--filter-scan-gap`. | | `exactly 100 users returned` warning | Jira 11.x behaviour of `user/search` when `user/list` is unavailable, or a proxy limiting results. Add `users.jsonl` from `users.sql`. | | `invalid JSON line in export/...jsonl` | The database client wrapped or truncated long lines. psql: use `-A -t`. mysql: use `--batch --raw`. sqlcmd: use `-y 0` and not `-W`. Save the file as UTF-8. | | sqlcmd: `'JIRA_SCHEMA' scripting variable not defined` | Add `-v JIRA_SCHEMA=jiraschema` (or `dbo`). | | PowerShell: `running scripts is disabled on this system` | `powershell -ExecutionPolicy Bypass -File .\snapshot.ps1 ...` or `Unblock-File .\snapshot.ps1`. | | bash on Windows prints odd characters or `\r` errors | Use Git Bash or WSL with `jq` installed; the script strips CRLF written by `jq.exe`. | ## Development and tests The scripts have no dependencies beyond those listed above. The tests use Node.js 22: ```bash npm ci npm test # every runtime found locally (bash, pwsh, Windows PowerShell) node tests/e2e.mjs --runtime bash node tests/e2e.mjs --update # regenerate tests/expected/ after an intended output change ``` - [`tests/mock-jira.mjs`](tests/mock-jira.mjs) is a mock Jira DC server that serves realistic recorded responses from [`tests/fixtures/`](tests/fixtures) by path and query (including paged responses, a 429 with `Retry-After`, 400/403 for invisible filters and boards, and a context path). It answers anything but GET with 405 and logs every request, so the tests prove the scripts are read-only. - [`tests/e2e.mjs`](tests/e2e.mjs) runs each edition against it (DC 9.12 REST only, 9.12 with SQL exports, 11.0 with `--projects` and basic auth, and error cases), validates the output against the schema with Ajv, compares it with the same golden files in [`tests/expected/`](tests/expected) for every runtime (so the editions must produce equivalent output), and checks that no secret appears in the output or console. - [`tests/sql/`](tests/sql) holds a model of the Jira tables the queries read. In CI it seeds PostgreSQL, MySQL and SQL Server, runs every query with the real client, compares the result with [`tests/fixtures/sql-export/`](tests/fixtures/sql-export), and feeds each database's output through the script. - CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)): shellcheck and PSScriptAnalyzer; bash on Ubuntu with jq 1.6 and the current jq; bash 3.2 on macOS; PowerShell 7, Windows PowerShell 5.1 and Git Bash on Windows with a cross-runtime equivalence check; the SQL job. ## License [MIT](LICENSE), copyright Nox Development.