Stay Updated

New tutorials, tips, and Atlassian insights. No spam, unsubscribe anytime.

L
LeanZero

An approachable expert helping teams simplify their Atlassian ecosystems. Sharing knowledge and building community, one solution at a time.

Services

  • Atlassian Migrations
  • Atlassian FastShift
  • Atlassian Maintenance
  • Forge App Development
  • AI Development Consultation

Topics

  • Jira
  • Jira Service Management
  • Confluence
  • Bitbucket
  • Atlassian Forge
  • Cloud Migration
  • Local AI
  • AI Coding
  • All topics

Company

  • Blog
  • Tutorials
  • Contact

Community

  • Join Discord
  • Support this site

© 2026 LeanZero. All rights reserved.

Privacy Policy|Terms of Service|Service Level Agreement|Trust Center
LZ·/PORTFOLIO·REV 2.6
  1. Home
  2. Portfolio
  3. Migration Audit Toolkit
Open-source migration toolkit

Migration Audit Toolkit

Three read-only Node.js tools that answer the question every Atlassian migration eventually has to answer out loud — did everything actually arrive?

View on GitHubRead the manual
Read-only, always Client-ready workbooks Compares two instances Apache-2.0

What silently breaks

A migration report tells you what the migration believes it did. It is generated by the same process that did the work, from the same assumptions — which makes it the one source that cannot independently confirm anything.

Self-verification is not verification

If the assumption that produced a defect is the same assumption used to check for it, the check passes. That is not dishonesty on the tool's part — it is a structural limit. The questions that matter have to be asked from outside.

Zero looks exactly like clean

If an issue security level hides a project's issues from the auditing account, the audit reports nothing missing and reads as good news. "This project is empty" and "my account cannot see this project" produce identical output.

Configuration drift breaks things later

A renamed priority quietly empties a filter. A missing link type makes every link restore fail. An issue-type scheme that differs by one type breaks every board built on it. All cheap to check in advance, all expensive to find after cutover.

What is in the box

Three tools, each answering one question, each producing a spreadsheet you can hand to a client. Nothing here writes to Jira — the remedy is always a separate, deliberate decision.

find_missing_issues

Which work items are on DC and missing from Cloud?

Compares by issue key across both instances and produces an .xlsx with one tab per affected project plus a summary. Supports label-based key remapping, so an issue recreated under a new key is not reported as missing.

dc_cloud_field_report

Which DC custom fields exist on Cloud, and why don't the rest?

Matches by name and emits a two-sheet workbook — the DC-to-Cloud field map, and the DC fields with no Cloud equivalent, each with a reason. Run it before the migration to predict, and after to confirm.

jira_config_comparator

Do source and target actually agree?

Compares issue types and schemes, link types, priority schemes and priorities, issue hierarchy, sprint and board configuration, and time-tracking settings between two Cloud instances, and reports every difference.

How every tool in this repository behaves

The same operating model throughout, deliberately — so that knowing one tool means knowing all of them.

Read-only is the point, not a limitation

An audit that could write is an audit whose findings you have to second-guess. These tools read two instances and produce a workbook. Every remedy lives in a sibling repository, behind its own deliberate decision.

Prove the negative before you believe it

Before a zero becomes a finding, prove the credentials can see that project's issues at all — on that same project. Permission and issue-security filters are per object, so a positive control somewhere else proves nothing.

Output a client can check

One tab per affected project, written only where the count is non-zero, plus a summary. The workbook is the evidence: anyone can open it and redo the arithmetic without taking your word for anything.

Run the configuration audit first

Every difference the comparator finds is one you would rather fix while the target is still empty. Running it after the data migration tells you why something broke; running it first stops it breaking.

Start here

git clone https://github.com/leanzero-srl/leanzero-atlassian-migration-audit-toolkit.git
cd leanzero-atlassian-migration-audit-toolkit/find_missing_issues
npm install
cp .env.example .env      # DC_BASE_URL, DC_PAT, CLOUD_BASE_URL, CLOUD_API_TOKEN

node main/find_missing_issues.js

The manual

Every question this repository raises, answered in order: what it reads and what it changes, why the migration's own report cannot answer this, why an empty result is not proof, what each of the three audits covers, and how to present the findings without overstating them. 17 sections.

Contents
Set up and read the result honestly
  • 01What do I need, and what does it change?
  • 02The migration report says it succeeded. Why do I need this?
  • 03The audit found nothing missing. Can I trust that?
The three audits
  • 04Which issues did not make it?
  • 05Which custom fields will not map — and why?
  • 06Do the two instances actually agree?
Every script, and how to run it
  • 07jira_config_comparator — how do I run it?
  • 08dc_cloud_field_report — how do I run it?
  • 09find_missing_issues — how do I run the core audit?
  • 10find_missing_issues — which script proves the audit is right?
  • 11find_missing_issues — how do I turn the findings into an import?
Turning findings into work
  • 12The audit found gaps. What fixes them?
  • 13How do I present this to a client without overstating it?
When the numbers look wrong
  • 14A project reports zero missing. Can I believe it?
  • 15It reports missing issues that clearly exist.
  • 16It is extremely slow.
  • 17How do I present this to a client without overstating it?

01What do I need, and what does it change?

Node 18 or newer, read access to both instances, and one shared .env. Nothing in this repository writes to Jira — every tool reads two instances and produces a spreadsheet.

The shared .env

DC_BASE_URL=https://jira-dc.example.com
DC_PAT=your-personal-access-token          # or DC_USERNAME + DC_PASSWORD
CLOUD_BASE_URL=https://your-site.atlassian.net
CLOUD_API_TOKEN=base64-of-email-colon-apitoken

Start here

git clone https://github.com/leanzero-srl/leanzero-atlassian-migration-audit-toolkit.git
cd leanzero-atlassian-migration-audit-toolkit/find_missing_issues
npm install
cp .env.example .env
node main/find_missing_issues.js
CarefulThe custom-field ids in `gather_missing_data.js` are placeholders. They are customfield_10001-style stand-ins, not live ids, and they will not match your instance — resolve yours with GET /rest/api/2/field. For the fields a create screen requires but createmeta hides, probe_required_fields.js discovers them empirically: it attempts a create, reads each "Field X is required" error, supplies a synthesized value, retries until the create succeeds, then deletes the probe issue.
NoteRead-only is the point, not a limitation. An audit that could write is an audit whose findings you have to second-guess. These three tools exist so that the answer to "did everything arrive" comes from somewhere other than the process that did the migrating.

02The migration report says it succeeded. Why do I need this?

Because that report is generated by the process that did the work, from the same assumptions. It is the one source that cannot independently confirm anything.

A migration report tells you what the migration believes it did. If the assumption that produced a defect is the same assumption used to check for it, the check passes. That is not dishonesty on the tool's part — it is a structural limit of self-verification.

The questions that have to be asked from outside

  • Which issues exist on Data Center and do not exist on Cloud?
  • Which Data Center custom fields have no Cloud counterpart — and why not?
  • Do the two instances actually agree on issue types, link types, priorities, hierarchy and sprint configuration, or only look like they do?

03The audit found nothing missing. Can I trust that?

Only after you have proved your credentials can see the project's issues at all. "This project is empty" and "my account cannot see this project's issues" produce identical output.

CarefulA negative that authorises a decision must be proved, not observed. A count of zero, an empty list, a 404, a "no results" — before any of those becomes a finding, prove the query can see the thing at all, on the same object. A positive control on a different project proves nothing, because permission and issue-security filters are per project.

The positive control, per project

  1. 1Pick one issue you know exists on both sides, in the project reporting zero.
  2. 2Fetch it by key with the audit's own credentials, on both instances.
  3. 3If either fetch fails, the zero is a visibility artefact, not a finding. Fix the access first — the [admin toolkit](https://github.com/leanzero-srl/leanzero-jira-admin-toolkit) has the tools for it — then re-run.
NoteThe usual culprit is an issue security level that the auditing account is not a member of. It is invisible in every direction: no error, no warning, just a smaller number that reads as good news.

04Which issues did not make it?

find_missing_issues compares by issue key across both instances and produces an .xlsx with one tab per affected project, plus a summary. Freeze the source first or the answer moves while you compute it.

The comparison is by issue key, which is the one identifier intended to survive a migration. Output is a workbook with one tab per project — written only where the missing count is greater than zero, so the workbook is a work list rather than a wall of empty tabs — plus a summary sheet.

CarefulFreeze Data Center, or discard the checkpoint and scan fresh. If DC is still live, every checkpoint is stale the moment it is written and the "missing" list grows for reasons that have nothing to do with the migration. A stale checkpoint is worse than no checkpoint, because it looks like data.
LimitCloud paging is the bottleneck, and it is structural. Data Center happily returns 1,000 issues per page; Cloud's /search/jql caps at 100. On a large corpus that 10:1 ratio, not the network, is what sets the runtime. Plan for it — and do not "optimise" by lowering the DC page size to match, which only makes both sides slow.
TipLabel-based key remapping is supported, for issues that had to be recreated under a new key. When a project's counter has already passed the original key, that key cannot be re-created — the recreated issue gets a new one and the old key is carried in a label. The audit reads those, so a recreated issue is not reported as missing.

05Which custom fields will not map — and why?

dc_cloud_field_report matches DC fields to Cloud fields by name and emits a two-sheet workbook: the map, and the DC fields with no Cloud equivalent, each with a reason.

Two sheets, two questions

DC to Cloud Field Map
Every DC field that has a Cloud counterpart, with both ids. This is what a field-copy tool would act on — read it before you run one.
DC Fields NOT on Cloud
Every DC field with no match, and the reason. This is the pre-migration risk list and the post-migration gap list, depending on when you run it.
TipRun it twice: before and after. Before the migration it predicts what will not map, while there is still time to create the fields or decide the data is not worth carrying. After, it confirms what actually did not — and the difference between the two runs is itself informative.
CarefulName matching has false twins. Two fields called Vertical, one of them a (migrated)-suffixed duplicate, are not the same field. The report shows both; deciding which is which is your job, not the tool's, and a tool that decided for you would be guessing at ten thousand issues' worth of data.
LimitA field can exist and still be invisible. If a Cloud field is not on a screen or has no context for the project, it will appear in the map as present and behave, from a user's point of view, as absent. The map answers "does the field exist", not "can anyone see it".

06Do the two instances actually agree?

jira_config_comparator diffs the configuration that everything else depends on, between two Cloud instances, and reports every difference.

What it compares

  • Issue types, and issue type schemes
  • Issue link types
  • Priority schemes, and individual priorities
  • Issue hierarchy — project configurations and their issue type mappings
  • Sprint and board configuration
  • Time tracking and booking settings

These are the foundations. A renamed priority quietly empties a filter; a missing link type makes a link restore fail for every issue; an issue-type scheme that differs by one type breaks every board built on it. All of them are cheap to check in advance and expensive to discover after cutover.

TipRun it before the data migration, not after. Every difference this finds is one you would rather fix while the target is still empty. Running it afterwards tells you why something broke; running it first stops it breaking.

07jira_config_comparator — how do I run it?

Run this first, before any data moves. Every difference it finds is one you would rather fix while the target is still empty.

Run it

cd jira_config_comparator && npm install

node jira-config-comparator.js \
  --source https://source.atlassian.net \
  --target https://target.atlassian.net \
  --email you@example.com --token "$TOKEN" \
  --output report.json

# Only the differences that will actually break something:
node jira-config-comparator.js --source … --target … --business-critical-only

Flags

FlagWhat it does
--source <url> / --target <url>The two Cloud instances.
--email / --tokenCredentials. The account needs read access on both.
--output <path>Where the report is written.
--summaryPrint a condensed summary instead of the full diff.
--business-critical-onlySuppress cosmetic differences and show only what will break something.
--severity <level>Filter by critical, high, medium or low.
--ignore-descriptionsIgnore description-only differences, which are noisy and rarely matter.

What it compares

  • Issue types, and issue type schemes
  • Issue link types
  • Priority schemes, and individual priorities
  • Issue hierarchy — project configurations and their issue type mappings
  • Sprint and board configuration
  • Time tracking and booking settings
TipStart with `--business-critical-only`. A full diff between two real instances runs to hundreds of rows, most of them harmless. The critical set is the one that empties a filter, breaks a board, or makes a link restore fail for every issue.

08dc_cloud_field_report — how do I run it?

Configured entirely from .env, no flags. Run it before the migration to predict what will not map, and after to confirm what did not.

Run it

cd dc_cloud_field_report && npm install && cp .env.example .env
# DC_BASE_URL, DC_USERNAME, DC_PASSWORD, CLOUD_BASE_URL, CLOUD_API_TOKEN
# STAMP is an optional filename suffix so before/after runs do not overwrite

node field_report.js

What it writes to ./out/

dc_cloud_field_report_<stamp>.xlsx
Two sheets — DC to Cloud Field Map (every DC field with a Cloud counterpart, both ids) and DC Fields NOT on Cloud (every field with no match, and the reason). Written when an exceljs is resolvable.
dc_to_cloud_field_map_<stamp>.csv
The map, always written, so the findings are greppable and diffable.
dc_fields_not_on_cloud_<stamp>.csv
The gaps, always written.
CarefulName matching has false twins. Two fields called Segment, one of them a (migrated) duplicate, are not the same field. The report shows both — deciding which is which is your job, and a tool that decided for you would be guessing at ten thousand issues' worth of data.
LimitA field can exist and still be invisible. If a Cloud field is not on a screen, or has no context for the project, it appears in the map as present and behaves — from a user's point of view — as absent. The map answers "does the field exist", not "can anyone see it".

09find_missing_issues — how do I run the core audit?

Configured from .env, scoped by PROJECTS. It compares by issue key across both instances and writes a workbook with one tab per affected project.

The core run

cd find_missing_issues && npm install && cp .env.example .env

node main/find_missing_issues.js                      # all projects
PROJECTS=ABC,DEF node main/find_missing_issues.js     # scope it
AUDIT_CONCURRENCY=4 node main/find_missing_issues.js  # throttle

Environment variables that change behaviour

VariableWhat it does
PROJECTS / AUDIT_PROJECTSComma-separated project keys to scope the audit.
AUDIT_CONCURRENCYParallel project scans. Lower it on 429.
CHUNK_SIZE / PROJECT_CHUNK_SIZEPage and batch sizes. DC returns 1,000 per page; Cloud caps at 100.
MIGRATION_CUTOFFIgnore issues created after this date — the ones that were never supposed to migrate.
BACKFILL_LABELThe marker label used to identify issues recreated under a new key.
ORPHAN_STATUSHow to classify issues whose parent is missing.
CarefulFreeze Data Center, or discard the checkpoint and scan fresh. If DC is still live, every checkpoint is stale the moment it is written and the "missing" list grows for reasons unrelated to the migration. A stale checkpoint is worse than none, because it looks like data.

10find_missing_issues — which script proves the audit is right?

Four of them, and they exist because a missing-issues report is exactly the kind of number people act on. Run them before you send the workbook to anyone.

The verification scripts

ScriptWhat it proves
check_dc_completeness.jsThe false-negative guard. Re-enumerates DC keys per project and asserts the count of distinct keys retrieved matches what DC reports. If the scan under-fetched, every downstream number is wrong and this is what catches it.
finalize_report.jsPost-processes the checkpoint: for every candidate-missing key, does an exact Cloud key resolution rather than trusting the bulk scan.
validate_missing.jsIndependent re-validation of the corrected report, reading the per-project tabs back.
audit_missing_v2.jsA second, independently-written audit path. When two implementations agree, the number is trustworthy; when they disagree, you have learned something.
TipRun `check_dc_completeness.js` first, every time. An audit that silently fetched 900 of 1,000 keys reports 100 phantom missing issues, and nothing else in the pipeline will notice.

11find_missing_issues — how do I turn the findings into an import?

Seven scripts that build import-ready CSVs with hierarchy and required fields intact. Read-only until the very last step.

The backfill pipeline, in order

  1. 1`check_keys_free.js` — re-confirm every target DC key is still free in Cloud. The importer preserves keys, so a taken key silently changes the outcome.
  2. 2`gather_missing_data.js` — pull the full DC data for the missing issues. Edit its field-id block first — the ids ship as placeholders.
  3. 3`probe_required_fields.js` — discover every required field per project and issue type, including the validator- and behaviour-enforced ones createmeta hides. It attempts a create, reads each "Field X is required" error, supplies a synthesized value, retries until it succeeds, then deletes the probe issue.
  4. 4`build_status_map.js` — build the per-project map of DC status to exact Cloud status name.
  5. 5`resolve_parents.js` — resolve the current Cloud key for every referenced parent or epic, since a parent may have moved.
  6. 6`generate_import_csvs.js` — build one CSV per project, with hierarchy preserved through the unified Parent field and rows ordered epics, then standard issues, then sub-tasks.
  7. 7`validate_csvs.js` — check structural and hierarchy integrity, and re-confirm every external parent key.
  8. 8Import via Jira's CSV importer, then `add_backfill_label.js --apply` to tag every imported issue so the sync_* tools can scope to exactly that batch.
Note`add_backfill_label.js` exists because of a JQL limit, not a preference. A creator-based JQL matches tens of thousands of unrelated issues, and an explicit key list blows the JQL GET length limit. A marker label is the only scoping that survives both. It adds the label without touching other labels, with notifyUsers=false.
CarefulKeys below the project counter cannot be recreated with their original key. Jira will assign a new one. The pipeline handles this by carrying the old key in a label — but it means "100% of keys preserved" is a claim you should verify rather than assume.

12The audit found gaps. What fixes them?

Each finding maps to a specific tool in the sibling repositories. The audit is deliberately read-only, so the fix is always a separate, deliberate decision.

Finding to remedy

The audit saysThe remedy
Issues missing on CloudRe-run the migration for that project, or recreate and then backfill with the [Jira Issue Data Toolkit](https://github.com/leanzero-srl/leanzero-jira-issue-data-toolkit) — comments, links, parents and attachments each have a tool.
DC field with no Cloud counterpartCreate the field on Cloud, then sync_custom_fields in the Issue Data Toolkit.
Field mapped but data missingUsually a (migrated) duplicate holds it — sync_same_instance_fields.
Priority or issue type renamedFix the configuration, then rewrite_filter_refs in the [Workflow & Automation Toolkit](https://github.com/leanzero-srl/leanzero-jira-workflow-automation-toolkit) for the JQL that referenced the old name.
Counts under-reporting because of visibility[Jira Admin Toolkit](https://github.com/leanzero-srl/leanzero-jira-admin-toolkit) — add_group_to_security_levels, then re-run the audit.

13How do I present this to a client without overstating it?

Say what was checked, what was not, and what you could not verify. An audit that hides its blind spots is worse than no audit, because it is believed.

What a defensible audit report contains

  1. 1The scope. Which projects, which date range, which instance versions, and the exact account the audit ran as — because that account's visibility bounds everything below it.
  2. 2The positive control. Evidence that the credentials could see issues in each project reporting zero. Without it, every zero is unproven.
  3. 3The findings, with counts, per project — and the raw workbook attached, so anyone can check the arithmetic.
  4. 4What was not checked. Attachments, worklogs, history, app data — say so explicitly. These tools compare keys, fields and configuration, and nothing else.
  5. 5What is unverified. If a project could not be scanned, or a field's twin is ambiguous, name it. Say it plainly rather than papering over a shallow check with confident language.
CarefulA miserable result described honestly is worth more than a good one described confidently. "Nine projects clean, one unverifiable because our account cannot see it" is a useful report. "All ten projects clean" — when one of them was invisible — is worse than useless, because it will be believed and acted on.

14A project reports zero missing. Can I believe it?

Only after a positive control on that same project. An empty result and a project you cannot read are indistinguishable from the outside.

The positive control

  1. 1Pick one issue you know exists on both sides, in the project reporting zero.
  2. 2Fetch it by key with the audit's own credentials, on both instances.
  3. 3If either fetch fails, the zero is a visibility artefact, not a finding.
  4. 4Fix the access — usually an issue security level the account is not in — then re-run.
CarefulA positive control on a different project proves nothing. Permission and issue-security filters are per object. The control has to be on the object you are drawing the conclusion about.

15It reports missing issues that clearly exist.

Almost always an under-fetch or a key that changed. Both have a dedicated script.

Symptom to cause

SymptomCauseFix
A whole block of consecutive keys reported missingThe DC scan under-fetched — a page was dropped or a request failed silently.check_dc_completeness.js. It asserts distinct-key counts per project and is designed to catch exactly this.
Scattered keys reported missing that you can open in CloudThe bulk scan's JQL did not match them, but an exact key lookup does.finalize_report.js re-resolves every candidate by exact key.
Issues recreated under a new key counted as missingThe original key was below the project counter and could not be reused.Set BACKFILL_LABEL so label-based key remapping recognises them.
Two audits disagreeGood — that is the point of having two.audit_missing_v2.js is an independent implementation. Reconcile the difference before trusting either.
Counts drift between runsData Center is still live.Freeze it, or accept that the number is a moving target and say so in the report.

16It is extremely slow.

Cloud paging is the bottleneck and it is structural, not a bug you can tune away.

Data Center happily returns 1,000 issues per page. Cloud's /search/jql caps at 100. On a large corpus that 10:1 ratio, not the network, is what sets the runtime — so plan for it rather than trying to optimise it.

What actually helps

ChangeEffect
Scope with PROJECTSAudit the projects that matter first. The workbook is per-project anyway.
Raise AUDIT_CONCURRENCY carefullyMore projects in parallel. Back off at the first 429 — Cloud rate-limits per tenant, not per project.
MIGRATION_CUTOFFExcludes issues created after the migration, which were never candidates.
Do not lower the DC page size to match CloudThat makes both sides slow and fixes nothing.

17How do I present this to a client without overstating it?

Say what was checked, what was not, and what you could not verify. An audit that hides its blind spots is worse than no audit, because it is believed.

What a defensible report contains

  1. 1The scope — which projects, which date range, which instance versions, and the exact account the audit ran as, because that account's visibility bounds everything below it.
  2. 2The positive control — evidence the credentials could see issues in each project reporting zero.
  3. 3The findings, per project, with the raw workbook attached so anyone can redo the arithmetic.
  4. 4What was not checked — attachments, worklogs, history and app data are outside these tools. Say so.
  5. 5What is unverified — name any project that could not be scanned, and any field pairing that stayed ambiguous.
CarefulA miserable result described honestly beats a good one described confidently. "Nine projects clean, one unverifiable because our account cannot see it" is useful. "All ten clean" — when one was invisible — is worse than useless, because it will be believed and acted on.

What it does, and what it will not do

Stated up front, because discovering a limit mid-cutover is the expensive way to find it.

What it does

  • A per-project workbook of issues present on Data Center and missing from Cloud, compared by key
  • Label-based key remapping, so an issue recreated under a new key is not counted as lost
  • A two-sheet DC-to-Cloud custom field map, with a stated reason for every field that has no counterpart
  • A configuration diff across issue types, link types, priorities, hierarchy, sprints and time tracking
  • CSV alongside every workbook, so the findings are greppable and diffable as well as readable
  • Read-only behaviour throughout — no tool in this repository can change either instance

What it will not do

  • It will not compare attachments, worklogs, issue history or app data — it compares keys, fields and configuration, and says so
  • It will not decide which of two same-named fields is the real one; it shows both and leaves the judgement to you
  • It will not tell you a field is visible, only that it exists — a field with no screen or no context behaves as absent
  • It will not warn you that your own account cannot see a project. That check is yours, and it is the first one
  • It will not fix anything. Every remedy is in a sibling repository, behind its own deliberate decision
Apache-2.0 licensed. Free to use, fork and ship inside your own migration.

Need to prove a migration landed?

The toolkit is free and Apache-2.0. If you need the audit run, the findings defended in front of a stakeholder, or the gaps closed afterwards, that is what we do.

View on GitHubAtlassian migrationsAsk on Discord