Forums

Articles
Create
cancel
Showing results for 
Search instead for 
Did you mean: 

How to keep format and images when migrating Issues from Bitbucket to JIRA?

Gene Parilov
July 9, 2026

It was an unexpected experience to migrate Bitbucket Issues into JIRA. All the Markdown formatting has disappeared -- not each issue is a mess within JIRA :  no titles, no code sections, no font formatting. Also, all the images are now not rendered and are shown as Markdown links. I would assume that once Bitbucket Issues are discontinued, all the images will be gone.

 

Is there a way to keep the format?

Is there a way to embed the images into those migrated Issues in JIRA in place of those Markdown links? Is there a script I can use?

                                      

2 answers

1 accepted

1 vote
Answer accepted
Gabriela
Rising Star
Rising Star
Rising Stars are recognized for providing high-quality answers to other users. Rising Stars receive a certificate of achievement and are on the path to becoming Community Champions.
July 9, 2026

Markdown is the problem. Bitbucket Issues store their text as Markdown, and Jira doesn't read Markdown on import — it expects its own format (ADF through the API, or wiki markup). The raw Markdown landed as literal text, which is why the titles, code blocks and formatting flattened, and why the images came through as ![](...) syntax instead of rendering.

No setting fixes this after the fact. The import already wrote the content in as plain text, so you'd have to redo the descriptions. The text has to be converted from Markdown to Jira's format — on a one-off you can paste Markdown into the Jira editor and it auto-formats, but for a bulk run you put the descriptions through a Markdown-to-ADF conversion inside whatever tool does the import. The images have to become real Jira attachments, because a Markdown image link points at where the file lived in Bitbucket and Jira won't go fetch it. Each one gets downloaded and re-uploaded to the issue, then referenced from the attachment.

Which importer did you use — a CSV import, the REST API, or a Marketplace migration app? The fix differs enough that you'd want to pin that down before re-running.

Gene Parilov
July 10, 2026

Thanks Gabriela. 

I have used the migration tool that Bitbucket provided.

I was hoping somebody in the community has already wrote a script that can do the steps you suggested in one run. I have 300+ Issues and converting them one by one is out of the question.

Gabriela
Rising Star
Rising Star
Rising Stars are recognized for providing high-quality answers to other users. Rising Stars receive a certificate of achievement and are on the path to becoming Community Champions.
July 10, 2026

@Gene Parilov here's a script that'll do the run. Rather than fix the mangled issues in place, it goes back to Bitbucket, reads each issue as its original Markdown while Issues still exists, converts that to Jira's format, and creates a clean issue. The images get pulled from Bitbucket and re-uploaded as attachments on the new issue, so they survive once Bitbucket takes Issues away — the part you can't leave sitting as links.

Node 18+, one dependency (npm i md-to-adf), a Bitbucket API token (app passwords are being switched off), and your Jira details as env vars. Run it with --dry-run first, then migrate one issue and eyeball it before letting it loose on all 300.

#!/usr/bin/env node
/**
 * Migrate Bitbucket Cloud Issues -> Jira Cloud with formatting + images preserved.
 *
 * Bitbucket Issues store text as Markdown; Jira Cloud stores rich text as ADF. The native
 * migration lands the raw Markdown as literal text (flattened headings/code) and leaves images
 * as dead ![](...) links. This script re-reads the ISSUES FROM BITBUCKET (the source of truth,
 * while they still exist), converts Markdown -> ADF, and re-uploads each image as a real Jira
 * attachment so the pictures survive after Bitbucket Issues is gone.
 *
 * HONEST LIMITS (read before running):
 *  - Markdown -> ADF uses the `md-to-adf` library. It handles the common cases (headings, code,
 *    bold/italic, lists, links) well; unusual/nested Markdown or tables may need a manual tweak.
 *    RUN --dry-run, then migrate ONE issue, eyeball it in Jira, and only then do the batch.
 *  - Images are uploaded as ATTACHMENTS on the Jira issue (guaranteed to preserve them). Inline
 *    re-embedding in the description body is left as attachments rather than ADF media nodes
 *    (embedding needs the media id and is fiddly) — the files are safe and visible on the issue.
 *  - It CREATES fresh Jira issues (clean import). Delete the botched ones, or adapt createIssue()
 *    to PUT-update existing issues if the native run preserved a Bitbucket-id mapping.
 *  - Verify the Bitbucket Issues endpoints against the current API docs before a big run.
 *
 * Setup:  Node 18+ (for global fetch/FormData/Blob), then  npm i md-to-adf
 * Env:
 *   BB_WORKSPACE, BB_REPO, BB_EMAIL, BB_API_TOKEN     (Bitbucket API token, NOT an app password)
 *   JIRA_BASE (https://your.atlassian.net), JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PROJECT (e.g. ENG)
 * Run:    node bb-issues-migration.js --dry-run      then      node bb-issues-migration.js
 */
const translate = require("md-to-adf");

const {
  BB_WORKSPACE, BB_REPO, BB_EMAIL, BB_API_TOKEN,
  JIRA_BASE, JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PROJECT,
} = process.env;
const DRY = process.argv.includes("--dry-run");
const ISSUE_TYPE = process.env.JIRA_ISSUE_TYPE || "Task";

const bbAuth = "Basic " + Buffer.from(`${BB_EMAIL}:${BB_API_TOKEN}`).toString("base64");
const jiraAuth = "Basic " + Buffer.from(`${JIRA_EMAIL}:${JIRA_API_TOKEN}`).toString("base64");
const BB = "https://api.bitbucket.org/2.0";

async function bbGet(url) {
  const r = await fetch(url, { headers: { Authorization: bbAuth, Accept: "application/json" } });
  if (!r.ok) throw new Error(`Bitbucket ${r.status} on ${url}`);
  return r.json();
}

// Page through every issue in the repo.
async function allIssues() {
  const out = [];
  let url = `${BB}/repositories/${BB_WORKSPACE}/${BB_REPO}/issues?pagelen=50&sort=id`;
  while (url) {
    const page = await bbGet(url);
    out.push(...(page.values || []));
    url = page.next || null;
  }
  return out;
}

// Download an image/attachment's bytes from Bitbucket.
async function bbDownload(issueId, name) {
  const r = await fetch(`${BB}/repositories/${BB_WORKSPACE}/${BB_REPO}/issues/${issueId}/attachments/${encodeURIComponent(name)}`,
    { headers: { Authorization: bbAuth } });
  if (!r.ok) throw new Error(`attachment ${name} -> ${r.status}`);
  return Buffer.from(await r.arrayBuffer());
}

async function createIssue(summary, adf) {
  const body = { fields: { project: { key: JIRA_PROJECT }, summary: summary.slice(0, 254), issuetype: { name: ISSUE_TYPE }, description: adf } };
  const r = await fetch(`${JIRA_BASE}/rest/api/3/issue`, {
    method: "POST",
    headers: { Authorization: jiraAuth, "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`Jira create ${r.status}: ${await r.text()}`);
  return (await r.json()).key;
}

async function uploadAttachment(issueKey, name, bytes) {
  const form = new FormData();
  form.append("file", new Blob([bytes]), name);
  const r = await fetch(`${JIRA_BASE}/rest/api/3/issue/${issueKey}/attachments`, {
    method: "POST",
    headers: { Authorization: jiraAuth, "X-Atlassian-Token": "no-check" }, // header is REQUIRED
    body: form,
  });
  if (!r.ok) throw new Error(`attach ${name} -> ${r.status}: ${await r.text()}`);
}

(async () => {
  const issues = await allIssues();
  console.log(`Found ${issues.length} Bitbucket issues${DRY ? " (dry-run — nothing will be written)" : ""}.`);
  let ok = 0, failed = 0;
  for (const it of issues) {
    const summary = it.title || `Bitbucket issue #${it.id}`;
    const md = (it.content && it.content.raw) || "";
    try {
      const adf = translate(md); // Markdown -> ADF (see HONEST LIMITS above)
      // collect this issue's attachments (images etc.)
      const att = await bbGet(`${BB}/repositories/${BB_WORKSPACE}/${BB_REPO}/issues/${it.id}/attachments`).catch(() => ({ values: [] }));
      const names = (att.values || []).map((a) => a.name);
      if (DRY) { console.log(`[dry] #${it.id} "${summary}" — ${names.length} attachment(s)`); ok++; continue; }

      const key = await createIssue(summary, adf);
      for (const name of names) {
        try { await uploadAttachment(key, name, await bbDownload(it.id, name)); }
        catch (e) { console.log(`   ! attachment ${name}: ${e.message}`); }
      }
      console.log(`#${it.id} -> ${key} (${names.length} file(s))`);
      ok++;
      await new Promise((r) => setTimeout(r, 300)); // be gentle on rate limits
    } catch (e) { console.log(`#${it.id} FAILED: ${e.message}`); failed++; }
  }
  console.log(`Done. ${ok} ok, ${failed} failed.`);
})().catch((e) => { console.error("Fatal:", e.message); process.exit(1); });

Honest about the limits so you're not surprised. The Markdown-to-ADF step leans on a library — everyday formatting like headings, code, lists, bold and links comes through fine, but unusual or deeply-nested Markdown and tables can land imperfect, so check a sample. And the images land as attachments on the issue. They don't re-embed inline at the exact spot in the text, but they're preserved and visible, which is what matters before Bitbucket removes them. For getting 300 issues across with their formatting and pictures intact, this does it.

0 votes
Viswanathan Ramachandran
Rising Star
Rising Star
Rising Stars are recognized for providing high-quality answers to other users. Rising Stars receive a certificate of achievement and are on the path to becoming Community Champions.
July 9, 2026

hi @Gene Parilov 

Out of curiosity, what led you to migrate Bitbucket Issues into Jira? They solve different problems, so understanding the use case would be really helpful. Also, what migration approach did you use? Was it Marketplace app, or a custom script/? That context could help. 

Gene Parilov
July 10, 2026

Bitbucket is winding down Issues. I need to move it somewhere so that I have access to the old Issues and can add new ones while working on the development project.

 

I used Bitbucket 's migration tool, which obviously does not do a job properly. 

Suggest an answer

Log in or Sign up to answer
DEPLOYMENT TYPE
CLOUD
PRODUCT PLAN
STANDARD
PERMISSIONS LEVEL
Product Admin Site Admin
TAGS
AUG Leaders

Atlassian Community Events