DEV Community

Cover image for Build your migration's gap list before you start, and make the differ refuse to guess
Mihai Perdum
Mihai Perdum

Posted on Originally published at leanzero.net

Build your migration's gap list before you start, and make the differ refuse to guess

Build your migration's gap list before you start, and make the differ refuse to guess

Key takeaways

  • END STATE: a list of what your migration will NOT carry, produced from your own inventory before day one, with partials called out separately.
  • Coverage is THREE-state, not two. Custom fields are copied by type; workflows only when linked to a project. A boolean set hides both.
  • Identical config names on two tenants are different entities. Remap on id, and refuse to map a name that is ambiguous at the destination.
  • The tooling appends '(migrated)' when it resolves a clash, so a name-keyed differ reports those as missing unless you normalise.
  • Different tools per path: JCMA for Jira and JSM, CCMA for Confluence, Data transfer for Cloud to Cloud.

Every Atlassian migration carries most of your data and leaves a remainder. The remainder is published, it differs per path, and almost nobody writes it down before starting.

This builds two artefacts from your own instance: a list of what the tool will not carry, and an id remap table that refuses to guess. The second one matters more than it sounds.

Prerequisites

Node 18 or later. Verified on v24.15.0.

Read access to your source instance, and to the destination if you have one.

An empty directory. Everything here runs offline — you export an inventory and diff it locally, so nothing writes to a tenant.

Know which path you are on, because the tools differ: Jira/JSM Data Center → Cloud uses the Jira Cloud Migration Assistant, Confluence Data Center → Cloud uses the separate Confluence Cloud Migration Assistant, and Cloud → Cloud uses Data transfer (the docs still carry its old name, Copy product data).

About forty minutes, most of it reading the copied-data page properly.

The insight that makes this worth doing

The obvious version of this exercise is a set difference: list what the tool copies, subtract it from what you have, and the remainder is your build.

That is wrong, and it is wrong in the direction that hurts. Atlassian's page does not say yes or no per kind. It says things like custom fields are copied — "Text, Date, Number, Time, Labels, URL, Select list…" — while "Project picker, Affected services, Responders" are not. It says "Workflows and permission schemes (that are not linked to any project won't be migrated)".

Those are partials. A boolean set filters them out as done, and you find out at cutover. So the coverage model here is three-state: carried, partial, not carried — and partials appear in the output as work, with the caveat attached.

I got this wrong myself. An earlier version of this used a boolean set, and its own worked example listed 19 dashboards as a rebuild — dashboards are copied. The tool was confidently inventing work.

  1. Encode the copied-data page as a three-state coverage map for your path.
  2. Export an inventory of your source instance by kind, as JSON.
  3. Compute the gap list, with partials reported separately from outright gaps.
  4. Diff the tenant-scoped config on id, normalising the "(migrated)" suffix.
  5. Prove the differ exits non-zero on an ambiguous name.
  6. Assign an owner to every line, which is what turns a list into scope.

Step 1 — Encode the page as three states

Open the current copied-data page for your path and read it properly. Save this as gaps.mjs:

export const CARRIED = "carried";
export const PARTIAL = "partial";
export const NOT_CARRIED = "not";

export const COVERAGE_C2C = {
  projects: CARRIED, spaces: CARRIED, users: CARRIED, groups: CARRIED,
  screens: CARRIED, "request-types": CARRIED, queues: CARRIED,
  "sla-calendars": CARRIED, "customer-organizations": CARRIED,
  dashboards: CARRIED, boards: CARRIED, filters: CARRIED,

  "custom-fields":      PARTIAL,  // named types only; project picker/responders excluded
  workflows:            PARTIAL,  // only when linked to a project
  "permission-schemes": PARTIAL,  // only when linked to a project
  "knowledge-bases":    PARTIAL,  // the link copies; article content needs its own handling

  "automation-rules":   NOT_CARRIED,
  "global-permissions": NOT_CARRIED,
  "app-data":           NOT_CARRIED,
};
Enter fullscreen mode Exit fullscreen mode

That map is Cloud → Cloud. On the DC path, build the equivalent from your assistant's own "what gets migrated" page — JCMA's for Jira and JSM, CCMA's for Confluence. The members are different; the method is identical.

How you know it worked: count the states and confirm none is empty.

node -e "import('./gaps.mjs').then(m=>{const v=Object.values(m.COVERAGE_C2C);
  console.log('carried',v.filter(x=>x==='carried').length,
              'partial',v.filter(x=>x==='partial').length,
              'not',v.filter(x=>x==='not').length);})"
Enter fullscreen mode Exit fullscreen mode

You should see all three counts above zero. A zero in partial means you read the page as a yes/no table, which is the mistake this whole step exists to prevent — go back and look for the words "some", "only", and any bracketed exclusion.

Step 2 — Export a source inventory

You need counts by kind. An array of identifiers or a plain number both work; anything else is refused rather than silently miscounted.

Save it as inv.json:

{
  "projects": 12,
  "workflows": 8,
  "custom-fields": 34,
  "automation-rules": 47,
  "global-permissions": 11,
  "app-data": 6,
  "dashboards": 19
}
Enter fullscreen mode Exit fullscreen mode

Keep the kind names identical to your coverage map. The method is a lookup, and a spelling mismatch moves an item into the wrong bucket.

How you know it worked: find kinds your coverage map has never heard of.

node -e "import('./gaps.mjs').then(m=>{const inv=require('./inv.json');
  console.log('unknown kinds:', Object.keys(inv).filter(k=>!(k in m.COVERAGE_C2C)));})"
Enter fullscreen mode Exit fullscreen mode

You should see unknown kinds: []. Anything listed there is either a spelling mismatch or a kind you have not classified yet — both are your problem to fix now, because gapList treats unknown kinds as work and will inflate your plan.

Step 3 — Compute the gap list

function countOf(kind, v) {
  if (Array.isArray(v)) return v.length;
  if (typeof v === "number" && Number.isFinite(v)) return v;
  throw new TypeError(`inventory["${kind}"] is ${typeof v}; give an array or a number`);
}

export function gapList(inventory, coverage = COVERAGE_C2C) {
  const out = [];
  for (const [kind, v] of Object.entries(inventory)) {
    const n = countOf(kind, v);
    if (n === 0) continue;
    const state = coverage[kind];
    if (state === CARRIED) continue;
    out.push({ kind, count: n, state: state || "unknown" });
  }
  return out.sort((a, b) => b.count - a.count);
}
Enter fullscreen mode Exit fullscreen mode

countOf throws rather than returning zero. An earlier version quietly returned an empty gap list when handed counts instead of arrays — no error, no warning, and a plan that said there was nothing to do.

How you know it worked: confirm it refuses input it cannot count.

node -e "import('./gaps.mjs').then(m=>{try{m.gapList({projects:{a:1}});console.log('NO THROW — bug')}
  catch(e){console.log('refused:',e.message)}})"
Enter fullscreen mode Exit fullscreen mode

You should see refused: inventory["projects"] is object; give an array or a number. If it prints NO THROW, the guard is missing and the tool will silently under-report.

Step 4 — Diff the config on id

Configuration is tenant-scoped: a priority named "P1" on each side is two objects with two ids. Anything you carry yourself — filters, boards, automation — references the id.

export const stripMigratedSuffix = (n) => String(n).replace(/\s*\(migrated\)\s*$/i, "");

export function idRemap(kind, source, dest) {
  const dupes = new Map();
  for (const d of dest) {
    const k = stripMigratedSuffix(d.name);
    dupes.set(k, (dupes.get(k) || 0) + 1);
  }
  const srcNames = new Set(source.map((s) => stripMigratedSuffix(s.name)));
  const ambiguous = [...dupes].filter(([n, c]) => c > 1 && srcNames.has(n)).map(([n]) => n);

  const byName = new Map();
  for (const d of dest) byName.set(stripMigratedSuffix(d.name), d);

  const remap = [], orphans = [], agreed = [];
  for (const s of source) {
    const n = stripMigratedSuffix(s.name);
    if (ambiguous.includes(n)) continue;              // refuse, do not guess
    const d = byName.get(n);
    if (!d) { orphans.push({ name: s.name, id: s.id }); continue; }
    if (String(d.id) === String(s.id)) agreed.push(n);
    else remap.push({ name: s.name, from: s.id, to: d.id });
  }
  return { kind, remap, orphans, agreed, ambiguous };
}
Enter fullscreen mode Exit fullscreen mode

Two details that are not decoration.

The suffix. Migration tooling appends (migrated) when it resolves a name clash, so the destination ends up holding Custom field A and Custom field A (migrated). A differ matching raw names reports those as missing, which sends you creating objects that already exist. Normalise before matching.

Ambiguity never enters **`remap*`.* If two destination objects share a name, that name is excluded from the translation table entirely. A poisoned row in a table you intend to run is worse than an absent one.

How you know it worked: confirm the suffix is normalised.

node -e "import('./gaps.mjs').then(m=>{const r=m.idRemap('cf',
  [{id:'1',name:'Team'}],[{id:'99',name:'Team (migrated)'}]);
  console.log('orphans',r.orphans.length,'remap',r.remap.length);})"
Enter fullscreen mode Exit fullscreen mode

You should see orphans 0 remap 1. If it prints orphans 1, the normalisation is not running and every clash the tool resolved will look like a missing object.

Step 5 — Prove it exits non-zero on ambiguity

A differ that warns and returns success will be consumed by something that ignores warnings. Make it fail. Save this as run.mjs:

import { readFileSync } from "node:fs";
import { gapList, idRemap, report } from "./gaps.mjs";

const inventory = JSON.parse(readFileSync("./inv.json", "utf8"));

const srcPri  = [{ id: "1", name: "P1" }, { id: "2", name: "P2" }, { id: "3", name: "Blocker" }];
const dstPri  = [{ id: "10001", name: "P1" }, { id: "2", name: "P2" }];
const srcLink = [{ id: "10000", name: "Blocks" }, { id: "10001", name: "Relates" }];
const dstLink = [{ id: "10100", name: "Blocks" },
                 { id: "10101", name: "Relates" }, { id: "10102", name: "Relates" }];

const remaps = [idRemap("priorities", srcPri, dstPri), idRemap("link types", srcLink, dstLink)];
console.log(report(gapList(inventory), remaps));

const ambiguous = remaps.reduce((n, r) => n + r.ambiguous.length, 0);
if (ambiguous) {
  console.log(`\nFAILED: ${ambiguous} ambiguous name(s). Resolve them before trusting this table.`);
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

The report function is a printer — full source is in gaps.mjs at the end of this article's repo layout, and any formatting you prefer will do; what matters is that every remap row and every orphan is printed rather than truncated.

How you know it worked. Run it and check the exit code:

node run.mjs; echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode

Real output:

NOT CARRIED — you build these:
    47  automation-rules
    11  global-permissions
     6  app-data

PARTIAL — copied with documented exclusions, still check every one:
    34  custom-fields
     8  workflows

ID REMAP — identical names, different ids:
  priorities: 1 to remap, 1 already agree, 1 missing, 0 AMBIGUOUS
     "P1"  1 -> 10001
     "Blocker" (3)  MISSING at destination
  link types: 1 to remap, 0 already agree, 0 missing, 1 AMBIGUOUS
     "Blocks"  10000 -> 10100
     "Relates"  AMBIGUOUS — two destination objects share this name; REFUSED

FAILED: 1 ambiguous name(s). Resolve them before trusting this table.
exit=1
Enter fullscreen mode Exit fullscreen mode

Three things to read there. Dashboards are absent from the gap list, because they are carried — the tool is not inventing work. Custom fields and workflows appear under PARTIAL, which is the whole reason for the three-state model. And "Relates" produced no mapping at all, with a non-zero exit.

Now remove the duplicate: delete { id: "10102", name: "Relates" } from dstLink and run again. You should get 0 AMBIGUOUS, a Relates remap row, no FAILED line, and exit=0. A check that cannot switch off is not testing anything.

Step 6 — Assign an owner to every line

The output is counts. Scope is counts with a name against each.

Forty-seven automation rules is not a task. "Priya rebuilds the 12 rules touching the release workflow before cutover, the other 35 after" is. Every orphan is a decision too — create it at the destination, map it elsewhere, or accept the loss — and each has a different owner.

How you know it worked: hand it to somebody who was not in the room. If they can say what they are responsible for without asking a question, it is scope. If they ask "so what do I do about app data", it is still a list.

What differs per path

Jira/JSM Data Center → Cloud (JCMA). It carries more than people expect, including dashboards, cross-project boards and filters, filter subscriptions, and automation flows. The remainder is mostly small in-project detail: custom field language translations, workflow properties and triggers, project avatars, canned responses, mail handlers, issue collectors, board sub-filters — plus global permissions and general configuration. Identity dominates the plan here for a different reason: username and userKey do not exist in Cloud, so every user reference resolves to an accountId.

Confluence Data Center → Cloud (CCMA). A different tool with a different list — global settings and permissions, application links, personal drafts, custom emojis, user-created macros and audit logs are among the things it leaves. Do not reuse the Jira map here.

Cloud → Cloud (Data transfer). Found at admin.atlassian.com → Data management → Data transfer → Create copy plan. It runs same-org or cross-org and gives you a plan with statuses to watch. Identity is smaller than on the DC path but not zero: group membership, permission changes on copied groups, blocklisted groups, users in inactive directories, and anyone who is not the same Atlassian account at the destination all need handling.

You now have a gap list that distinguishes what you build from what arrives partially finished, and a remap table that refuses to guess when two destination objects share a name — proven by a run that exits 1 and a second run that exits 0 once the duplicate is gone.

What this does not do is tell you whether the migration is a good idea, size the rebuild, or catch anything the copied-data page fails to mention. Re-read that page close to cutover. Everything here depends on it, and it moves — an earlier draft of this article was built on a stale reading of it and confidently listed carried objects as work.

More in our Atlassian Migrations collection.


Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.

Top comments (0)