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. Jira Issue Data Toolkit
Open-source migration toolkit

Jira Issue Data Toolkit

Twelve Node.js tools that find and repair the issue data a Jira migration lost — field values, comments, links, parents, attachments and security levels — using Data Center as the source of truth.

View on GitHubRead the manual
DC to Cloud Report → plan → audit → apply Notifications muted by default Apache-2.0

What silently breaks

A bulk migration reports success at the level of issues moved. It says nothing about what is inside them. Every failure below is real, silent, and found weeks later by a user rather than by the migration report.

Fields that arrive empty

Custom field ids differ between instances, so mapping happens on name — and the migration helpfully created a "(migrated)" duplicate with the same name. It populated that one. The field on your screens is blank, and nothing reported an error.

Content cut at 32,767 characters

Cloud's hard cap on the serialized ADF length for a description or comment. Longer bodies arrive truncated at exactly that length. Elsewhere, escape-doubling turns a single semicolon into four and mentions become @unknown.

Relationships and security gone

Links, parents and attachments vanish wherever only one end of the relationship migrated. Security levels disappear too — and that one fails in the direction nobody checks, because restricted issues land wide open with no error at all.

What is in the box

Twelve tools, one per failure mode, all sharing one .env file and one operating model. Write the credentials once and symlink them from every sibling directory.

Field values

sync_custom_fields

DC to Cloud field values

Copies custom-field values with DC as source of truth. Matches on name, and when two writable Cloud targets share one it refuses to guess — it reports the ambiguity for you to pin in a config file.

sync_same_instance_fields

"(migrated)" duplicate into the real field

Copies values from the (migrated) duplicate into the field that is actually on your screens, on one Cloud site. No Data Center needed. Handles type translation and denylists the known false twins.

sync_traffic_light_fields

Traffic-light / RAG fields

DC stores a string like "(,,red) Red"; Cloud stores a {shape, label} object. A reviewed lookup table bridges them, because a generic copier either skips the field or writes something that renders as garbage.

field_merge

Merge fields, across all data types

Same instance or cross instance, single or multiple tokens. Ships with an automation-field-checker that finds every rule referencing a field by BOTH id and name — run it before you delete anything.

Comments, text and attachments

sync_issue_comments

Comments that never arrived

Injects the missing DC comments, preserving per-comment visibility — the JSM internal/public flag as well as classic role and group restrictions. Every created comment is tagged, so re-runs skip it.

mend_comments

Comments that arrived damaged

Collapses escape-doubled ";;;;" runs back to what DC holds, and resolves @unknown mentions to real accounts by email. Comments only — never descriptions, never any other field.

recover_truncated_content

The 32,767-character cut

Finds every body cut at exactly that length, recovers the full text from DC with rich-text fidelity, and attaches it as a .docx — with all comments in chronological order, so the reviewer gets context, not a fragment.

sync_issue_attachments

Missing attachments

Re-uploads files present on DC and missing on Cloud, matched by filename plus byte size. Files over Cloud's size cap are reported for a human decision, not silently dropped.

Relationships and metadata

sync_issue_parents

Orphaned issues

Rebuilds sub-task parents, Epic Links and next-gen parents. Never overwrites an existing parent, never invents one when the DC parent is not on Cloud. Also does reporter and assignee.

sync_issue_links

Missing issue links

Recreates links preserving type and direction, normalising every entry to a canonical directed triple so a link that appears on both endpoints is created exactly once.

sync_security_levels

Lost issue security

Restores levels by name. Refuses to create levels that do not exist on Cloud — it emits a CSV of what must be created by hand first, because generating a security scheme nobody designed is worse than the gap.

tempo_worklog_resync

The Jira/Tempo desync

Repairs the three-way mess left when worklogs are moved with Jira's own REST API, which Tempo never sees. Redoes the move through Tempo so the real employee author survives.

How every tool in this repository behaves

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

Four phases: report → plan → audit → apply

The report tells you what the tool thinks maps to what. This is where a wrong field pairing is caught on ten issues instead of ten thousand, and it is the phase people skip. The plan is a JSON file you can read. Only then does anything write.

Names, not ids — so ambiguity is refused

Field ids differ across instances, so everything matches on name, and names are not unique. When two candidates are equally valid the tool refuses and reports. You pin the answer in a config file. No tool guesses which "Vertical" you meant.

Notifications muted by default

Writing to thousands of issues generates thousands of emails and gets the migration account throttled. Tools that write clone the project's notification scheme, strip it, use the copy, and restore the original — with a snapshot and a --restore-only mode for interrupted runs.

Idempotent, resumable, re-checked at write time

Every created object is tagged with its DC origin, so a second run skips it. --resume continues from the plan file, --retry-failed re-runs only what failed, and every row is re-validated against live state immediately before its write.

Start here

git clone https://github.com/leanzero-srl/leanzero-jira-issue-data-toolkit.git
cd leanzero-jira-issue-data-toolkit/sync_custom_fields
npm install
cp .env.example .env      # DC_BASE_URL, DC_PAT, CLOUD_BASE_URL, CLOUD_API_TOKEN

node main/sync_custom_fields.js field-report --projects ABC --limit 10
node main/sync_custom_fields.js plan --projects ABC --limit 200
node main/sync_custom_fields.js audit
node main/sync_custom_fields.js apply --dry-run
node main/sync_custom_fields.js apply

The manual

Every question this repository raises, answered in order: how to set it up and run it without emailing ten thousand people, how field matching goes wrong and what the tools do about it, how comments and attachments are repaired, and how to prove a run actually worked. 31 sections.

Contents
Set it up and run it safely
  • 01What do I need before I start?
  • 02What is the run order, and why four phases?
  • 03Will this email ten thousand people?
Field values
  • 04Why does field matching go wrong, and what does the tool do about it?
  • 05The migration populated a "(migrated)" copy and left my real field blank. Can I fix that in place?
  • 06Why do traffic-light fields need their own script?
  • 07I want to merge two fields and delete one. How do I know what will break?
Comments, text and attachments
  • 08Some descriptions stop mid-sentence. What is the 32,767 number?
  • 09My comments migrated but they are full of ";;;;" and "@unknown". Can that be repaired?
  • 10Some issues lost comments entirely. How do I put them back without duplicating?
  • 11How are missing attachments matched and re-uploaded?
Relationships, security and proof
  • 12Issues arrived orphaned. How does the parent get rebuilt?
  • 13Why do issue links need deduplication?
  • 14Security levels are missing. Why is that worse than it sounds?
  • 15We moved worklogs with Jira's API and Tempo did not notice. What is the recovery?
  • 16How do I actually prove a run worked?
The twelve tools, one at a time
  • 17What do all twelve share?
  • 18sync_custom_fields — how do I run it?
  • 19sync_same_instance_fields — how do I run it?
  • 20sync_traffic_light_fields — how do I run it?
  • 21field_merge — how do I run it, and what do I check first?
  • 22sync_issue_comments and mend_comments — how do I run them?
  • 23recover_truncated_content — how do I run it?
  • 24sync_issue_parents, sync_issue_links and sync_issue_attachments — how do I run them?
  • 25sync_security_levels and tempo_worklog_resync — how do I run them?
When it goes wrong
  • 26The write succeeded and the field is still empty.
  • 27It refuses to write, saying the field is ambiguous.
  • 28A run died and now nobody is getting Jira email.
  • 29429s, throttling, or the account gets blocked.
  • 30How do I restart a half-finished run without duplicating anything?
  • 31How do I prove the run actually worked?

01What do I need before I start?

Node 18 or newer, a Data Center account that can read every project, a Cloud API token that can write to them — and one .env file that every tool in the repository shares.

Every tool here is a plain Node.js CLI using the public REST API over Node's built-in https. Nothing is installed into either instance. Each one reads Data Center as the source of truth and writes to Cloud, or — in the case of sync_same_instance_fields — reads and writes one Cloud site.

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   # echo -n "you@example.com:ATATT..." | base64
TipWrite it once and symlink it. The variable names are identical across every tool, so ln -s ../sync_custom_fields/.env .env from any sibling directory is the intended setup. One credential rotation, not twelve.
CarefulEvery config file in the repository ships with placeholder ids. config/config.json, config/field_mappings.json and the field lists inside field_merge carry customfield_10001-style placeholders that will not match your tenant. Resolve your own ids with GET /rest/api/3/field (Cloud) and GET /rest/api/2/field (Data Center) before the first run — the field-report phase exists precisely to check that mapping on ten issues.
CarefulYour reading account must be able to see everything. If an issue security level or a permission scheme hides issues from the account you are using, every audit and every plan under-reports, and looks like good news. Prove your credentials can see a project's issues on that project before you trust a count of zero from it.

02What is the run order, and why four phases?

report → plan → audit → apply. The report is where a wrong field pairing is caught on ten issues instead of ten thousand, and it is the phase people skip.

The four phases

  1. 1Report. What does the tool think maps to what? For field tools this is the field-resolution report: DC field name, DC id, the Cloud candidates, and which one it chose. Run it on ten issues. Read every line. This is the cheapest place in the whole process to catch a wrong pairing.
  2. 2Plan. Build a JSON plan of every intended write and stop. The plan is a file you can open, grep and diff. Nothing is written to Jira.
  3. 3Audit. Spot-check a seeded sample against live state, and emit a human-reviewable CSV. This catches the case where the plan is internally consistent and wrong.
  4. 4Apply. --dry-run first — the full execution path with the PUT suppressed — then for real.

All four, on one tool

node main/sync_custom_fields.js field-report --projects ABC --limit 10
node main/sync_custom_fields.js plan --projects ABC --limit 200
node main/sync_custom_fields.js audit
node main/sync_custom_fields.js apply --dry-run
node main/sync_custom_fields.js apply

03Will this email ten thousand people?

Not by default. Tools that write clone the affected project's notification scheme, strip it, use the stripped copy for the run, and restore the original afterwards — with a snapshot, and a --restore-only mode for when a run dies mid-flight.

Writing to thousands of issues generates thousands of emails. Beyond the obvious problem, that is also how the migration account gets throttled or blocked mid-run, which turns one bad afternoon into a corrupted half-finished state.

How muting works

Snapshot
The project's current notification scheme assignment is recorded to disk before anything changes.
Clone and strip
The scheme is cloned, the events are stripped from the copy, and the copy is assigned for the duration of the run. The original scheme is never modified.
Restore
The original assignment is put back at the end of the run.
--restore-only
Restores pending snapshots and exits, doing no other work. Run this FIRST if a previous run was interrupted.
--no-mute-notifications
Opts out. Expect mail.
CarefulIf a run is interrupted, run `--restore-only` before anything else. A project left on a stripped notification scheme will silently stop notifying its real users, and nobody will report it — because the symptom is the absence of email.
NoteComment creation additionally passes ?notifyUsers=false, so watchers are not spammed even where a notification scheme is not in play.

04Why does field matching go wrong, and what does the tool do about it?

Custom field ids differ between DC and Cloud, so everything matches on name — and names are not unique. The rule throughout: when two candidates are equally valid, refuse and report.

A field called Vertical is customfield_23954 on Data Center and customfield_10305 on Cloud. There is no stable identifier that survives the move, so name is the only join key available. That works right up until the migration creates a Vertical (migrated) duplicate alongside the real Vertical, at which point one name has two writable Cloud targets and a copier that picks one is a coin flip applied ten thousand times.

What happens in each case

SituationBehaviour
One DC field, one Cloud field, same nameMapped. Reported in the field-resolution report.
One DC field, two writable Cloud fields with the same nameRefused. The ambiguity is reported for you to pin in config/field_overrides.json. Nothing is written.
DC field has no Cloud counterpartReported as unmapped. Nothing is written.
Cloud option label differs textually from the DC valueRemap it in config/option_value_map.json; matching on the DC value is case-insensitive.
Field is on no screen for the projectThe write succeeds and shows nothing. The API cannot warn you — see the verification section.

Pinning an ambiguous field

// config/field_overrides.json — reviewed AFTER reading
// reports/field_resolution_<runId>.md
{
  "overrides": [
    { "name": "Vertical", "dcFieldId": "customfield_23954", "cloudFieldId": "customfield_10305" }
  ]
}
TipThe denylist earns its keep. Sprint, Rank, Global Rank, Story Points and Development are denied by default: they are either Jira-managed, board-managed or app-managed, and writing them directly corrupts state that looks fine until a board is opened. Add your own false twins to it — fields that share a name and mean different things.

05The migration populated a "(migrated)" copy and left my real field blank. Can I fix that in place?

Yes — sync_same_instance_fields copies source to target on one Cloud site, with type translation, validation and an audit report. No Data Center needed.

This is the single most common post-migration field complaint, and it does not need DC at all. The data is already on Cloud; it is in the wrong field.

The shape of the problem

Source:  "Vertical (migrated)"  (customfield_12345) — populated with data
Target:  "Vertical"             (customfield_67890) — empty, on your screens

Configure the pairs explicitly

// config/config.json
{
  "jql": "(cf[10001] is not EMPTY OR cf[10002] is not EMPTY) ORDER BY updated DESC",
  "fieldPairs": [
    { "sourceName": "Approvers (migrated)", "targetName": "Approvers" },
    { "sourceName": "Start Date (migrated)", "targetName": "Start date" }
  ],
  "fieldDenylist": ["Sprint", "Rank", "Story Points", "Team", "Category", "Urgency"],
  "richTextOverwrite": "missing_only",
  "recheckBeforeApply": true,
  "muteNotifications": true
}
CarefulNot every same-named pair is the same field. "Team", "Category", "Urgency" and "Change type" are the classic false twins — a (migrated) duplicate whose option set means something different from the field it appears to shadow. Copying between them produces confidently wrong data. Denylist them until you have checked each one by hand.
LimitA user-picker field needs a user-shaped write. Copying the rendered display name of an Approvers field produces a field that looks populated in an export and is empty in the UI. The tool writes accountId, and reports the ones it cannot resolve.

06Why do traffic-light fields need their own script?

Because DC stores a string and Cloud stores an object. A generic copier either skips the field or writes a string that renders as garbage.

The same value on both sides

InstanceStored value
Data Center(,,red) Red — a string encoding the lamp positions
Cloud{ "shape": "🔴⚪⚪", "label": "Red" } — a structured object

The translation is a lookup, not an inference, so it lives in config/field_mappings.json where you can review it — one entry per field, with the DC field id, the Cloud field id, and every option's shape and label.

TipDo not guess the shape string. Create one value by hand in the Cloud UI and read it back over REST. An almost-right shape writes without error and renders wrong, and only a browser will tell you.

07I want to merge two fields and delete one. How do I know what will break?

Run the automation-field-checker first. It searches every automation rule for references to the field by BOTH id and name, which is the only way to catch 100% of usage.

Deleting a custom field that an automation rule references breaks the rule silently — it does not error, it just stops doing what it did. Searching for the field id alone misses every rule that refers to it by name in a smart value, and searching by name alone misses every structured component that refers to it by id.

The safe order

  1. 1Export your automation rules to JSON and run automation-field-checker against the file. No API calls needed, so you can run it as many times as you like.
  2. 2Read the report. It names the exact rules and the exact locations inside them.
  3. 3Fix or retire those rules first.
  4. 4Then merge, with merge_field_data.js — same instance or cross instance, single or multiple token keys, using the paginated search endpoint.
  5. 5Then, and only then, delete the source field.
Notemulti_field_copy.js handles the several-fields-at-once case, and jump_start_checklist_app.js exists for a specific aftermath: when data has been merged into a checklist app's field and the app's backend has not noticed. It appends a character to each value and restores it, which forces a re-sync. Disable the outgoing mail server before running that one.

08Some descriptions stop mid-sentence. What is the 32,767 number?

Cloud's hard cap on the serialized ADF JSON length for a description or comment. Anything longer arrives cut at exactly that length — a detectable, exact signature.

Because the cut is at an exact length, it is detectable with certainty rather than heuristically: recover_truncated_content scans every Cloud issue and flags any description or comment whose JSON.stringify(body).length is exactly 32767. No guessing about whether a document "looks" truncated.

What it does with them

  1. 1Recovers the full text from the matching Data Center issue, using expand=renderedFields to get HTML and keep rich-text fidelity.
  2. 2Generates .docx files: {KEY}_description.docx when the description was cut, and {KEY}_comment.docx containing all comments on that issue in chronological order when any comment was cut — so the reviewer reads full context, not a fragment.
  3. 3Splits anything over 10 MB into {KEY}_comment_1.docx, _2.docx, and so on.
  4. 4In --apply mode, uploads each .docx as an attachment on the matching Cloud issue.
LimitThe Cloud description and comment bodies are deliberately not modified. The cap is real; writing the full text back would just truncate it again. The recovered content is delivered as an attachment, where it is complete, searchable and permanently attached to the issue it belongs to.

09My comments migrated but they are full of ";;;;" and "@unknown". Can that be repaired?

Yes, and both defects have a deterministic fix. mend_comments repairs comments in place; it touches comments only, never descriptions, never any other field.

The two defects

SymptomCauseFix
Runs of ;;;; where DC has a single ;Escape-doubling during migrationThe DC comment body is the source of truth; Cloud runs are collapsed to match it exactly.
@unknown where a mention used to beDC [~username] mentions arrived as unresolved ADF mention nodesResolve by DC user email: GET /rest/api/2/user?username=… → email → Cloud /user/search?query=<email> → exact email match → accountId, then rewrite the ADF mention node.
NoteUnresolvable mentions are not left broken and not guessed at. They are replaced with plain text @<displayName> and logged, so the reader still sees who was meant while the log tells you exactly which accounts need attention.
TipScope it with MEND_CREATOR_ACCOUNT_IDS — a comma-separated list of the Cloud accountIds whose created issues you want mended, which in practice means the migration service accounts. That keeps the tool off issues created by real users after the cutover.

10Some issues lost comments entirely. How do I put them back without duplicating?

sync_issue_comments injects the missing DC comments and tags each one it creates with migration.dc_comment_id, so the next run skips it. Visibility is preserved per comment.

The tagging is what makes this safely re-runnable. Every comment the tool creates carries a property naming its DC origin, so a second run over the same JQL is a no-op rather than a duplication event — which matters, because the natural instinct after a partial failure is to run it again.

Visibility is preserved with the fidelity DC reports

JSM internal/public
The sd.public.comment flag is carried across, so an internal note does not become customer-visible.
Classic role and group restrictions
The visibility object is preserved per comment.
CarefulThis is the one to be most careful with. A comment that was internal on DC and lands public on Cloud is a disclosure, not a formatting bug. Dry-run a JSM project, then open three issues in the customer portal — not the agent view — and confirm the internal notes are absent.

11How are missing attachments matched and re-uploaded?

By filename plus byte size — precise enough not to re-upload a file that is already there, loose enough to survive the author and timestamp rewriting migration does.

Two phases

  1. 1Plan. Walk the Cloud JQL result set, read the DC and Cloud attachment lists for each issue, diff them, write a plan and a missing-attachments report. Read-only.
  2. 2Execute. Download each pending DC file to a temp path and POST /rest/api/3/issue/{key}/attachments. Every row is re-checked live immediately before the upload, so a stale plan cannot duplicate a file.
LimitTwo things cannot be fixed over REST. Cloud's per-file size cap is usually lower than DC's — files over it are reported, not uploaded, and need a human decision. And the attachment's author and creation date are set by Cloud to the uploading account and the current time; the original values cannot be set through the API by any means.

12Issues arrived orphaned. How does the parent get rebuilt?

By reading the DC parent, verifying the parent exists on Cloud, and only then writing. It never overwrites an existing parent and never invents one.

Hierarchy is stored as a reference to another issue's internal id, and internal ids do not survive a migration. When the parent and child move in different batches, the reference cannot be resolved and the child arrives orphaned. Boards still look right; every roll-up, epic burndown and portfolio view under them is wrong.

All three parent shapes are recovered

  • Sub-task parent
  • Story-under-Epic — the classic Epic Link custom field
  • Team-managed / next-gen parent

The three safety rules

  • Never overwrites — a Cloud issue that already has a parent is skipped, always.
  • Never invents — if the DC parent does not exist on Cloud, the row goes to a missing-parents report instead of being guessed at.
  • Re-checks at write time — a plan built yesterday cannot act on a state that changed today.
TipThe same machinery does --field reporter and --field assignee. And main/sanitize_subtask_parent_ids.js solves the adjacent CSV problem: a DC export's Parent id column holds DC internal ids, and Jira Cloud's importer needs Cloud internal ids. It rewrites the column and lists everything it could not resolve in a companion .unresolved.csv.

13Why do issue links need deduplication?

Because one physical link appears on both endpoint issues. Walking a JQL result set naively sees every link twice and creates it twice.

Every raw entry is normalised to a canonical directed triple — (typeName, outwardKey, inwardKey) — and deduplicated globally before anything is planned. Each link is planned and created exactly once, regardless of how many times the walk encountered it.

Then each triple is validated before it is planned

  • The link type must exist on Cloud
  • Both endpoint issues must exist on Cloud
  • The link must not already be there

Everything rejected is written to a skip report with its reason. That report is worth reading in its own right — a large "endpoint missing on Cloud" count means issues are missing, which is a different and bigger problem than missing links.

14Security levels are missing. Why is that worse than it sounds?

Because the failure is in the direction nobody checks. "No security level" is a valid state, so restricted issues land wide open and no error is raised anywhere.

sync_security_levels restores the level by name: look up the DC level, find the matching level in the Cloud project's assigned scheme, set it. It works when the project exists on both sides, the Cloud project has a security scheme assigned, and the level name exists in that scheme.

NoteIt refuses to create levels that do not exist on Cloud. Where a name has no Cloud equivalent, the issue is skipped and written to a CSV listing exactly which levels must be created by hand, in which schemes, first. Creating security levels programmatically and then filling them is how you end up with a scheme nobody designed.
CarefulVerify by absence. The right check is not "did the writes succeed". It is: log in as an account that should not see a restricted issue, and confirm it cannot. A count of successful PUTs proves nothing about who can now read what.

15We moved worklogs with Jira's API and Tempo did not notice. What is the recovery?

Redo the move through Tempo. Jira's worklog/move endpoint is Jira-core only, and using it leaves a three-way inconsistency that no amount of Jira-side work will fix.

The state you are left in

WhereWhat is there
Target issueThe moved worklogs as Jira-only copies, authored by the Tempo app account — wrong author, and zero records in Tempo, so they never appear in a timesheet.
Source issueThe originals as Tempo orphans — correct human author, but the backing Jira worklog is gone, so no Jira shadow.
TimesheetsDisagree with Jira in both directions.

The three prerequisites that each cost real hours to discover

  • Override Mode must be ON in Tempo, and the create/delete calls must send bypassPeriodClosuresAndApprovals: true — otherwise every write into a closed period fails with 403 "Period is closed".
  • Delete the orphan before creating its replacement. Tempo enforces a per-day hour cap and rejects the create if both exist at once.
  • Only ever move the oldest worklogs, identified precisely via the archive copy's tempo property — an exact cross-walk, not a heuristic.
CarefulVerify in Tempo, not in Jira. A worklog can exist in Jira and be absent from every timesheet — that is the entire bug. The only valid check is opening the affected person's Tempo timesheet for the affected period and confirming the hours and the author on both issues.

16How do I actually prove a run worked?

Not from the run summary. Open issues in a browser, enumerate every field the user will see, and check each one — because a value written to a field that is not on the screen writes successfully and shows nothing.

The verification that counts

  1. 1Open three issues in the browser and look at the field. This catches the single most common false positive in the whole repository: the write succeeded, and the field is not on the project's edit screen, so the user sees nothing.
  2. 2Enumerate the full expected result first, then check every item. Write down what a correct issue contains — every field, every comment, every link — before you look. One field working while ten are blank is a failure, not a partial win.
  3. 3Test the real target, not an easier neighbour. Check the specific project and the specific issues the stakeholder actually asked about, not a convenient row that exercises the happy path.
  4. 4For anything visibility-shaped, verify by absence. Log in as someone who should not see it.
  5. 5Re-run the plan phase. A clean second plan is good evidence the first run finished. A plan that still lists pending rows is telling you something.
CarefulA green number is not a passing test. "4,801 values written" is not "the field is populated", and it is not even "the field is visible". Every tool in this repository exists because a proxy metric once said something was fine and it was not. Assume the run is broken until a rendered issue proves otherwise.

17What do all twelve share?

One .env, one plan-file convention, and a set of flags that mean the same thing everywhere. Learn these once and every tool is familiar.

The shared flag vocabulary

FlagMeaning, in every tool that has it
--dry-runFull execution path with the write suppressed. Plans and reports are still written.
--applyIn the four-phase tools, the phase that writes. Without it you get a plan.
--plan-only / --execute-only / --resumeIn the two-phase tools: build the plan and stop, or run an existing plan.
--plan-file <path>Which plan JSON to load. Defaults to the most recent.
--limit <n>Cap the number of issues. Your smoke-test lever.
--concurrency <n>Parallel writes. Lower it on 429.
--retry-failedRe-run only the rows whose status is failed. Fix the cause first.
--jql / --projects / --keys / --issueFour ways to scope: a full JQL, a project list, an explicit key list, or one issue.
--no-mute-notificationsSkip the notification-scheme swap. Expect mail.
--restore-only-notificationsRestore notification schemes from snapshots and exit. Run this first after any interrupted run.
--user-map-cache <path>Cache DC-username-to-Cloud-accountId lookups across runs. Saves thousands of calls.
Tip`--issue KEY` is the fastest way to learn a tool. One issue, --dry-run, read the plan. Every tool below supports scoping down to a single issue, and every failure is easier to read at that size.

18sync_custom_fields — how do I run it?

Four phases, DC as source of truth, matched on field name. The field-report phase is the one that catches a wrong pairing before it touches ten thousand issues.

The four phases

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

node main/sync_custom_fields.js field-report --projects ABC --limit 10
node main/sync_custom_fields.js plan --projects ABC --limit 200
node main/sync_custom_fields.js audit
node main/sync_custom_fields.js apply --dry-run
node main/sync_custom_fields.js apply

Flags beyond the shared set

FlagWhat it does
--config <path>The run config: JQL, concurrency, field denylist, overwrite policy. Defaults to config/config.json.
--recheck / --no-recheckRe-validate each row against live Cloud state immediately before writing. On by default; leave it on.
--user-map-cache <path>Persist resolved identities between runs.

The config keys that change behaviour

fieldDenylist
Never write these. Sprint, Rank, Story Points and Development are denied by default because they are board- or app-managed and writing them directly corrupts state that looks fine until a board is opened.
preferMigratedTarget
When both a field and its (migrated) twin are writable, which one wins. Default false — the real field.
allowClearWhenDcEmpty
Whether an empty DC value clears the Cloud value. Default false, so the tool only ever fills.
richTextOverwrite
missing_only writes rich text only where Cloud has none. The alternative overwrites, which loses Cloud-side edits.
muteNotifications
Clone-and-strip the project notification scheme for the run.
CarefulRead `reports/field_resolution_<runId>.md` before the plan. It lists every DC field, the Cloud candidates, and which one was chosen — or that the tool refused because two were equally valid. Ambiguities you do not pin in config/field_overrides.json are simply not written.

19sync_same_instance_fields — how do I run it?

The same four phases, one Cloud site, no Data Center. Pairs are explicit — you name source and target — so there is nothing for the tool to guess.

Run it

cd sync_same_instance_fields && npm install && cp .env.example .env
# then edit config/config.json — fieldPairs is the important part

node main/sync_same_instance_fields.js field-report --jql "project = ABC" --limit 10
node main/sync_same_instance_fields.js plan --jql "project = ABC"
node main/sync_same_instance_fields.js apply --dry-run
node main/sync_same_instance_fields.js apply

Flags beyond the shared set

FlagWhat it does
--extend-contextsWhen the target field has no context covering a project in scope, extend it rather than skipping those issues. Changes field configuration — decide deliberately.
--manifest <path>Write a manifest of everything changed, for handing to a reviewer.
--restoreReverse a previous apply using that manifest.
CarefulDenylist the false twins before the first apply. Team, Category, Urgency and Change type frequently exist as a (migrated) duplicate whose option set means something different from the field it appears to shadow. Copying between them writes confidently wrong data that nobody notices for months.
Tip`--extend-contexts` is the fix for "the write succeeded and the field is still empty". If the target field's context does not include the project, Jira accepts the write and stores nothing. The field-report phase flags this; the flag resolves it.

20sync_traffic_light_fields — how do I run it?

Two phases driven by a reviewed lookup table. The config is the whole tool — get the shape strings right and there is nothing else to configure.

Run it

cd sync_traffic_light_fields && npm install && cp .env.example .env
# edit config/field_mappings.json first — see the caution below

node main/sync_traffic_light_fields.js --dry-run --limit 20
node main/sync_traffic_light_fields.js --plan-only
node main/sync_traffic_light_fields.js
node main/sync_traffic_light_fields.js --resume

Flags

FlagDefaultWhat it does
--field <name>allProcess one configured field.
--limit <n>noneCap tickets per field.
--concurrency <n>10Parallel Cloud PUTs.
--forceoffWrite even where the Cloud value is already set.
--plan-only / --execute-only / --resume / --plan-file / --retry-failed—The standard two-phase controls.
CarefulDo not type the shape string from the documentation. Create one value by hand in the Cloud UI, read the issue back over REST, and copy the exact shape value into the config. An almost-right shape writes without error and renders as garbage — the API will never tell you, only a browser will.

21field_merge — how do I run it, and what do I check first?

Three scripts. Run the automation-field-checker before any of them, because deleting a field an automation rule references breaks the rule silently.

The safe order

  1. 1Export your automation rules to JSON from the Jira admin UI.
  2. 2Run `automation-field-checker` against that file. It searches by field id and name, which is the only way to catch every reference — id-only misses smart values, name-only misses structured components.
  3. 3Fix or retire the rules it names.
  4. 4Merge with merge_field_data.js, or multi_field_copy.js for several fields at once.
  5. 5Only then delete the source field.

merge_field_data.js

node merge_field_data.js \
  --url https://your-site.atlassian.net \
  --email you@example.com --token "$JIRA_TOKEN" \
  --source-field customfield_10001 --target-field customfield_10002 \
  --jql "project = ABC" --dry-run

# Cross-instance instead of same-instance:
node merge_field_data.js \
  --source-url https://source.atlassian.net --source-email … --source-token … \
  --target-url https://target.atlassian.net --target-email … --target-token … \
  --source-field customfield_10001 --target-field customfield_20001

Flags

FlagWhat it does
--url / --email / --tokenSame-instance credentials.
--source-url / --source-email / --source-token and the --target-* trioCross-instance credentials. Supplying these switches the tool into cross-instance mode.
--source-field / --target-fieldThe two field ids.
--jql / --project-key / --issue-keysThree ways to scope.
--overwrite-existingWrite even where the target already has a value. Off by default.
--no-skip-emptyProcess issues whose source field is empty, instead of skipping them.
--batch-size <n>Issues per page. Default 50.
--dry-runReport only.
Tip`jump_start_checklist_app.js` exists for one specific aftermath: data merged into a checklist app's field where the app's backend has not noticed. It appends a character to each value and removes it again, forcing a re-sync. Disable the outgoing mail server before running it.

22sync_issue_comments and mend_comments — how do I run them?

Siblings that share credentials and machinery. One injects comments that never arrived; the other repairs comments that arrived damaged. Both are safely re-runnable.

Inject missing comments

cd sync_issue_comments && npm install && ln -s ../mend_comments/.env .env

node main/sync_issue_comments.js --issue ABC-123 --dry-run
node main/sync_issue_comments.js --projects ABC --limit 50 --dry-run
node main/sync_issue_comments.js --projects ABC --apply

Repair damaged comments

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

node main/mend_comments.js --probe --issue ABC-123     # inspect, change nothing
node main/mend_comments.js --issue ABC-123 --dry-run
node main/mend_comments.js --projects ABC --apply

Flags that matter

FlagToolWhat it does
--strict-visibilitysync_issue_commentsRefuse to create a comment whose DC visibility cannot be reproduced exactly on Cloud, rather than creating it with weaker visibility.
--strict-visibility-skippedsync_issue_commentsReport what strict mode skipped, so you can handle those by hand.
--mentionbothControl mention handling — resolve to accountId, or fall back to plain text.
--newest <n>bothOnly the N most recent comments per issue.
--creators <ids>mend_commentsRestrict to issues created by these accountIds — in practice, the migration service accounts.
--probemend_commentsInspect and report without planning. The read-only way in.
--emit <path>bothWrite the computed ADF to disk instead of sending it, for review.
--max-user-search-concurrency <n>mend_commentsThrottle identity lookups separately from writes; user search rate-limits sooner.
CarefulVerify comment visibility in the customer portal, not the agent view. A comment that was internal on Data Center and lands public on Cloud is a disclosure, not a formatting bug. Use --strict-visibility on any JSM project and read what it skipped.
NoteEvery comment sync_issue_comments creates is tagged with a migration.dc_comment_id property, and every write goes out with ?notifyUsers=false. That is what makes re-running after a partial failure a no-op rather than a duplication event.

23recover_truncated_content — how do I run it?

Scan for bodies cut at exactly 32,767 characters, recover the full text from DC, and attach it as a .docx. The Cloud bodies themselves are never modified.

Run it

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

node main/recover_truncated_content.js --projects ABC --limit 100     # scan + generate docx
node main/recover_truncated_content.js --projects ABC --apply         # also upload them

Flags

FlagWhat it does
--applyUpload the generated .docx files as attachments. Without it they are only written to reports/docx/.
--max-docx-bytes <n>Split threshold. Files over this become _1.docx, _2.docx, … Default 10 MB.
--key-map <path>DC key to Cloud key map, for issues recreated under a new key.
--restore-onlyRestore notification-scheme snapshots and exit.
--max-old-space-size / --expose-gcNode memory flags, passed through. Very large corpora need them — a full scan holds a lot of ADF in memory.
NoteComment docx files contain every comment on the issue, in chronological order — not just the truncated one. That is deliberate: a reviewer opening the attachment needs the conversation, not a fragment.

24sync_issue_parents, sync_issue_links and sync_issue_attachments — how do I run them?

The same two-phase shape, scoped by Cloud JQL. Each plans read-only first, and each re-checks live state immediately before writing.

Parents — also does reporter and assignee

node main/sync_issue_parents.js --jql 'project = ABC' --plan-only --limit 50
node main/sync_issue_parents.js --resume --plan-file logs/master_<ts>.json --dry-run
node main/sync_issue_parents.js --jql 'project = ABC'
node main/sync_issue_parents.js --field reporter --jql 'project = ABC'

Links and attachments

node main/sync_issue_links.js --jql 'key = ABC-123' --dry-run
node main/sync_issue_links.js --plan-only --limit 50
node main/sync_issue_links.js

node main/sync_issue_attachments.js --jql 'key = ABC-123' --dry-run
node main/sync_issue_attachments.js --resume --retry-failed

Tool-specific flags

FlagToolWhat it does
--field parent\|reporter\|assigneeparentsWhich field to sync. Default parent.
--epic-link-field <id> / --parent-link-field <id>parentsDC custom field ids, auto-discovered if omitted. Supply them when discovery picks the wrong field.
--max-bytes <n>attachmentsOverride Cloud's reported max attachment size.
--keep-tempattachmentsKeep downloaded files after upload, to inspect what was sent.
--restore-onlylinks, attachmentsRestore notification schemes and exit.

The verifiers that ship with sync_issue_parents

verify_parent_mapping.js, verify_plan.js, batched_verify.js
Re-read Cloud and confirm the plan actually landed. Run one of these before you call the parent backfill done.
dc_match_audit.js, explore_dc_relations.js
Inspect what DC really holds for a sample, before trusting the plan.
check_dupes_and_structure.js, count_still_missing.js, probe_still_missing.js
Post-run gap analysis — what is still unparented, and why.
main/sanitize_subtask_parent_ids.js
A different job: rewrites a DC-exported CSV's Parent id column from DC internal ids to Cloud internal ids so Jira Cloud's importer accepts it. Unresolved values are blanked and listed in a companion .unresolved.csv.

25sync_security_levels and tempo_worklog_resync — how do I run them?

The two tools with the sharpest preconditions. One refuses to create what is missing; the other needs Tempo configured a specific way before it will work at all.

Security levels

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

node main/sync_security_levels.js --plan-only --limit 200   # emits the missing-levels CSV
# create any missing levels by hand in the Cloud admin UI, then:
node main/sync_security_levels.js --dry-run
node main/sync_security_levels.js
node main/test_scenarios.js                                  # verify one issue end to end

Security-level flags

FlagWhat it does
--project <KEY>Restrict to one project.
--create-missing-levelsCreate levels that do not exist on Cloud. Off by default and best left off — a generated security scheme is one nobody designed.
--plan-only / --execute-only / --resume / --plan-file / --retry-failed / --concurrency / --limitThe standard two-phase controls.

Tempo re-sync

cd tempo_worklog_resync && npm install && cp .env.example .env
# .env needs CLOUD_BASE_URL, CLOUD_API_TOKEN and a Tempo OAuth 2.0 token

node fix_tempo_desync.js --dry-run --limit 1
node fix_tempo_desync.js --limit 1
node fix_tempo_desync.js --delay 500

Tempo preconditions — none are optional

  • Override Mode must be ON in Tempo, and the create/delete calls must send bypassPeriodClosuresAndApprovals: true, or every write into a closed period fails 403 "Period is closed".
  • Delete the orphan before creating its replacement — Tempo enforces a per-day hour cap and rejects the create if both exist at once.
  • Only the oldest worklogs move, identified via the archive copy's tempo property. That is an exact cross-walk, not a heuristic, and it is why the tool will not touch recent entries.
CarefulVerify in Tempo, not in Jira. A worklog can exist in Jira and be absent from every timesheet — that is the entire bug. Open the affected person's Tempo timesheet for the affected period and confirm the hours and the author on both issues.

26The write succeeded and the field is still empty.

The single most common false positive in this repository. Three causes, all of which return 2xx, and none of which the API will warn you about.

Symptom to cause

What you seeCauseFix
204, field blank in the UIThe field is not on the project's edit or view screen.Add it to the screen, or check with fetch_screen_fields in the admin toolkit. The value IS stored — it is just not rendered.
204, field blank in the API tooThe field has no context covering that project, so Jira accepted and discarded the write.sync_same_instance_fields --extend-contexts, or extend the context by hand.
Value written to the wrong fieldTwo Cloud fields share the name and the tool picked the other one.Read reports/field_resolution_<runId>.md and pin the pairing in config/field_overrides.json.
User-picker field looks emptyA display name was written where an accountId was required.The tool writes accountId; if it could not resolve one it reports the user. Check the unresolved list.
Careful`editmeta` is not a whitelist. A field absent from editmeta can still accept a write, and a field present in it can still store nothing. Neither direction is reliable — only reading the issue back, in the browser, is.

27It refuses to write, saying the field is ambiguous.

Working as designed. Two writable Cloud fields share one name, and guessing at ten thousand issues is worse than stopping.

Resolving it

  1. 1Open reports/field_resolution_<runId>.md and find the field. It lists every candidate with its id, type and context.
  2. 2Decide which is the real one — usually the one without the (migrated) suffix, and the one that appears on your screens.
  3. 3Add an entry to config/field_overrides.json with the DC field id and the Cloud field id.
  4. 4Re-run the field-report phase and confirm the ambiguity is gone before planning.

The override

{
  "overrides": [
    { "name": "Segment", "dcFieldId": "customfield_10001", "cloudFieldId": "customfield_20001" }
  ]
}
TipIf the two candidates genuinely mean different things — a (migrated) duplicate whose option set differs from the field it shadows — do not pin it. Denylist it. Copying between false twins is how you get confidently wrong data.

28A run died and now nobody is getting Jira email.

The notification scheme is still swapped for the stripped copy. Fix it before anything else — the symptom is silence, so nobody will report it.

Restore, immediately

# From the tool whose run was interrupted:
node main/<tool>.js --restore-only-notifications

# The two-phase tools spell it slightly differently:
node main/sync_issue_links.js --restore-only

The snapshot is written to disk before the swap, so the restore is exact. It is safe to run when there is nothing to restore — it reports zero and exits.

CarefulMake this the first command after any interrupted run, before --resume and before diagnosing anything else. A project left on a stripped scheme silently stops notifying its real users for as long as it takes someone to notice.

29429s, throttling, or the account gets blocked.

Lower concurrency, and check that notification muting is actually on. The email volume is usually what triggers it, not the API calls.

What to change, in order

SymptomChange
Sporadic 429 on writesDrop --concurrency to 2–3. Retries back off exponentially, but sustained over-concurrency outruns the backoff.
429 on user search specifically--max-user-search-concurrency 2. User search rate-limits far sooner than issue endpoints.
Mailbox flooded, account throttledNotification muting was off or failed. Stop, --restore-only-notifications, confirm the scheme swap works on one project, restart.
Very slow with low concurrencyExpected. Cloud /search/jql caps at 100 results per page; that ratio, not the network, sets the pace.

30How do I restart a half-finished run without duplicating anything?

Re-run with --resume. Everything created is tagged with its DC origin, so a second pass skips it rather than repeating it.

The restart

  1. 1Restore notification schemes first — --restore-only-notifications.
  2. 2Read the plan's failure reasons. They are recorded per row; a single cause usually explains all of them.
  3. 3Fix the cause. Retrying against an unchanged cause reproduces the failures and makes the log harder to read.
  4. 4`--resume --retry-failed`. Rows already applied are skipped; only pending and failed rows run.
  5. 5Re-run the plan phase afterwards. A clean second plan is good evidence the run finished.
NoteA plan built yesterday cannot corrupt anything today: --recheck is on by default, so every row is re-validated against live state immediately before its write. Rows that no longer match are skipped, not forced.

31How do I prove the run actually worked?

Enumerate what a correct issue contains, then open three issues and check every item. Not the run summary, and not a count.

The verification that counts

  1. 1Write down what a correct issue looks like before you look — every field, comment, link and attachment the user expects. Deciding afterwards means checking whatever is easy.
  2. 2Open three issues in the browser. Check every item on that list, not the first one that worked.
  3. 3Test the real target, not a convenient neighbour — the specific project and issues the stakeholder asked about.
  4. 4For anything visibility-shaped, verify by absence. Log in as someone who should not see it and confirm they cannot.
  5. 5Re-run the plan. Nothing pending means the run finished; rows still pending mean it did not.
CarefulA green number is not a passing test. "4,801 values written" is not "the field is populated", and it is not even "the field is visible". Every tool in this repository exists because a proxy metric once said something was fine and it was not.

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

  • Custom field values, with type translation and reviewed option-value remapping
  • Comment visibility per comment — JSM internal/public, plus classic role and group restrictions
  • Truncated descriptions and comments, recovered in full and attached as .docx with context
  • Sub-task parents, Epic Links and next-gen parents — all three shapes
  • Issue links with type and direction, created exactly once each
  • Issue security levels, restored by name, against the level that actually exists on Cloud
  • A reviewable JSON plan and a human-readable CSV for every phase, before anything writes

What it will not do

  • It will not guess between two same-named Cloud fields — it refuses and asks you to pin the mapping
  • It will not create issue security levels that do not exist on Cloud; it reports what a human must create first
  • It will not restore an attachment's original author or creation date — Cloud sets both, and no REST call can change them
  • It will not upload a file over Cloud's per-file size cap; those are reported for a human decision
  • It will not rewrite a truncated body in place — the 32,767 cap is real, so the recovered text is delivered as an attachment
  • It will not tell you a field is visible. A value written to a field that is not on the screen writes successfully and shows nothing
Apache-2.0 licensed. Free to use, fork and ship inside your own migration.

Missing data after a Jira migration?

The toolkit is free and Apache-2.0. If you would rather have someone who has already found these failures the hard way run the audit with you, that is what we do.

View on GitHubAtlassian migrationsAsk on Discord