← Back to blog

How to migrate from Jira to GitHub Issues without losing history

Move Jira tickets into GitHub Issues while preserving comments, attachments, history, links, and deletion proof through a migration ledger.

Jelle De Laender

Line drawing of issue cards moving from a crowded tracker into an organized issue board.

A practical Jira migration needs a durable mapping between old tickets and new GitHub issues.

I recently cancelled Jira.

That sounds like an administrative decision, but it became a small migration project. We had years of tickets in Jira: open work, closed work, screenshots, PDFs, ZIP files, comments, linked issues, parent tickets, old sprint assignments, duplicate resolutions, and enough history to explain many product decisions.

At the same time, our active work had moved to GitHub. Repositories were there. Pull requests were there. New tickets were going into GitHub Issues. Keeping Jira around only as a historical archive no longer made sense.

The tempting migration path is simple: export Jira to CSV, import the open tickets somewhere else, and call it done.

I did not want that.

I wanted to end up with GitHub Issues as the source of truth, while keeping enough Jira context to understand old decisions later. I also wanted to delete the migrated Jira tickets afterwards, so Jira would no longer show a misleading backlog.

This post describes the migration approach I would use again.

The short version

The migration worked because it had a few strict rules:

  • Every Jira ticket gets a durable GitHub issue mapping.
  • Closed Jira tickets are migrated too, then closed in GitHub.
  • Attachments are copied somewhere that survives after Jira is gone.
  • Raw Jira changelog history is summarized, not dumped.
  • Jira links are preserved first and rewritten later using the mapping ledger.
  • Deletion is a separate phase.
  • A Jira ticket is only marked deleted after a follow-up Jira read returns 404.

The most important artifact was not a script. It was a ledger.

Why not just export Jira?

Jira exports are useful as backups. You should make one before doing anything destructive.

But an export does not give you a working backlog. It does not create GitHub issues. It does not preserve issue discussions in the place where future work happens. It also does not solve links between old Jira tickets and new GitHub issues.

For my use case, the goals were:

  • keep GitHub Issues as the future source of truth;
  • preserve useful Jira context, not every noisy field;
  • migrate closed and resolved tickets as closed GitHub issues;
  • preserve attachments outside Jira;
  • preserve parent, epic, and linked-issue relationships;
  • preserve enough development references to understand old branches and pull requests;
  • delete Jira tickets only after the GitHub copy was verified.

That last point changes the design. If you plan to delete the source system, the migration needs more than "the create call succeeded". It needs readback verification.

Start with a ledger

The ledger is a local file or database that maps old Jira keys to new GitHub issues.

At minimum, it should record:

{
  "jira_key": "PROJECT-123",
  "jira_url": "https://example.atlassian.net/browse/PROJECT-123",
  "jira_project": "PROJECT",
  "jira_issue_type": "Bug",
  "jira_status": "Resolved",
  "jira_resolution": "Fixed",
  "jira_priority": "High",
  "github_repo": "owner/repo",
  "github_issue": 456,
  "github_url": "https://github.com/owner/repo/issues/456",
  "github_state": "closed",
  "labels": [
    "source:jira",
    "project:project",
    "type:bug",
    "jira-status:resolved",
    "jira-resolution:fixed"
  ],
  "copied": {
    "description": true,
    "comments": {
      "copied": true,
      "jira_count": 3
    },
    "attachments": {
      "copied": true,
      "jira_count": 2
    },
    "history": {
      "raw_history_copied": false,
      "compact_summary_copied": true
    }
  },
  "linked_jira_issues": [
    {
      "key": "PROJECT-122",
      "relationship": "blocks",
      "github_url": "https://github.com/owner/repo/issues/455"
    }
  ],
  "delete_ready": true,
  "delete_blockers": [],
  "deleted_from_jira": false
}

This ledger solves practical problems:

  • avoiding duplicate imports;
  • resuming a migration after rate limits or failures;
  • rewriting Jira links after more tickets are migrated;
  • proving what happened to a ticket after Jira is gone;
  • generating safe deletion lists.

You can use JSON, SQLite, PostgreSQL, or anything else. The format matters less than the discipline: every migrated ticket must pass through the ledger.

Use scoped batches

Do not start with "migrate everything".

Start with a scope you can reason about:

  • one project;
  • one sprint;
  • one year of closed tickets;
  • one small batch of open tickets;
  • one set of tickets linked to blockers.

For example, the JQL shape can be simple:

project = PROJECT AND statusCategory = Done ORDER BY created ASC

or:

project = PROJECT AND sprint = 123 ORDER BY rank ASC

The exact JQL depends on your Jira setup. The point is to keep the batch small enough that you can inspect failures and improve the migration rules.

Fetch Jira issues with the API

Jira Cloud API authentication can be done with an Atlassian email address and API token. Start with the Jira Cloud platform REST API v3 documentation and the Atlassian basic auth guide for REST APIs.

A minimal environment file might look like this:

JIRA_BASE_URL="https://example.atlassian.net"
JIRA_EMAIL="you@example.com"
JIRA_API_TOKEN="redacted"
GITHUB_REPO="owner/repo"

Keep that file out of git.

For Jira Cloud, prefer the current issue search endpoints. Other useful Jira API references for this migration are the Issues API for readback, changelogs, and deletes, and the Issue attachments API for durable attachment preservation.

import https from "node:https";

const auth = Buffer
  .from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`)
  .toString("base64");

function jiraGet(path) {
  return new Promise((resolve, reject) => {
    https.get(`${process.env.JIRA_BASE_URL}${path}`, {
      headers: {
        Authorization: `Basic ${auth}`,
        Accept: "application/json"
      }
    }, (res) => {
      let body = "";
      res.on("data", (chunk) => body += chunk);
      res.on("end", () => {
        resolve({
          status: res.statusCode,
          json: JSON.parse(body || "{}")
        });
      });
    }).on("error", reject);
  });
}

async function searchJira(jql) {
  const issues = [];
  let nextPageToken = null;

  do {
    const query = new URLSearchParams({
      jql,
      maxResults: "100",
      fields: [
        "summary",
        "description",
        "status",
        "resolution",
        "priority",
        "issuetype",
        "created",
        "updated",
        "comment",
        "attachment",
        "issuelinks",
        "parent"
      ].join(",")
    });

    if (nextPageToken) {
      query.set("nextPageToken", nextPageToken);
    }

    const response = await jiraGet(`/rest/api/3/search/jql?${query}`);

    if (response.status !== 200) {
      throw new Error(JSON.stringify(response.json));
    }

    issues.push(...(response.json.issues || []));
    nextPageToken = response.json.isLast ? null : response.json.nextPageToken;
  } while (nextPageToken);

  return issues;
}

Treat this as a starting point, not a complete migration script. Jira custom fields differ per installation.

Generate GitHub issues deliberately

Each GitHub issue should be readable by a human. Do not just dump JSON into the body.

A useful migrated issue body contains:

  • a short migration note;
  • the original Jira summary;
  • the original Jira description converted to Markdown;
  • a table of important Jira fields;
  • linked issue information;
  • attachment information;
  • development references if available.

For labels, I found this kind of shape useful:

source:jira
project:project-key
type:bug
priority:high
jira-status:resolved
jira-resolution:fixed
sprint:current-sprint

Resolved Jira tickets should be created in GitHub and then closed. That keeps history searchable without polluting the active backlog.

Copy comments, but keep them readable

Jira comments should become GitHub comments.

Each copied comment should preserve:

  • Jira author display name;
  • created timestamp;
  • updated timestamp if different;
  • the comment body converted to Markdown.

I would not preserve Atlassian profile links as important data. The display name is usually enough.

A copied comment can start like this:

Copied from Jira comment.

Author: Jane Developer
Created: 2024-03-14 09:31 UTC
Updated: 2024-03-14 10:02 UTC

---

The original comment text goes here.

Keep comments separate from the issue body. It makes the imported issue easier to scan, and it mirrors how the discussion originally happened.

Preserve attachments outside Jira

Attachments were the most important edge case.

Jira attachment URLs are not enough. If you cancel Jira, those links may break.

You need a durable copy.

The strategy I used:

  • download every Jira attachment;
  • compute a hash;
  • upload binary files to durable GitHub-hosted storage;
  • add a GitHub issue comment with an attachment index;
  • verify the final links work before deleting Jira.

For this migration, GitHub release assets worked well for non-image files. The release acted as a project-level archive of Jira attachments. The corresponding GitHub issue then received a comment with links to its files.

A comment can look like this:

<!-- jira-attachments:v1 key=PROJECT-123 -->
## Jira attachments preserved

| File | Size | SHA-256 | Preserved copy |
| --- | ---: | --- | --- |
| screenshot.png | 148 KB | `abc123...` | [Download](https://github.com/owner/repo/releases/download/jira-migration-attachments/PROJECT-123-screenshot.png) |
| logs.zip | 2.1 MB | `def456...` | [Download](https://github.com/owner/repo/releases/download/jira-migration-attachments/PROJECT-123-logs.zip) |

The marker is important. It lets your script detect that the attachment preservation comment already exists.

Also verify generated URLs. During our migration, one generated GitHub attachment URL accidentally included an encoded trailing quote. It looked almost right, but it 404'd. Since then, I treat URL readback as part of attachment verification.

Do not dump raw Jira history

Jira changelog history is useful, but raw Jira history is often unreadable.

Full history includes things like rank changes, sprint churn, watcher changes, repeated field edits, and large description diffs. Copying all of that into GitHub makes the issue worse.

Instead, add a compact pre-delete summary.

Example:

<!-- jira-predelete-preservation:v1 key=PROJECT-123 -->
## Jira pre-delete preservation summary

This comment preserves useful Jira history before deleting the source ticket.

- Jira key: PROJECT-123
- Final Jira status: Resolved
- Final Jira resolution: Fixed
- Status timeline: Open -> In Progress -> Resolved
- Description edits: 4 edits between 2022-01-10 and 2022-02-03
- Attachment events: 2 attachments added
- Linked issue changes: linked to PROJECT-122 as "blocks"
- Sprint changes: appeared in 2 sprints
- Development references: 1 Bitbucket branch, 1 pull request

Raw Jira changelog entries were intentionally not copied because they were too noisy.

This preserves the signal without turning GitHub into a Jira changelog archive.

Preserve links, then rewrite them

Jira links are tricky because you may migrate tickets in batches.

Ticket `PROJECT-123` might link to `PROJECT-122`, but `PROJECT-122` might not be migrated yet.

The initial migration can preserve the Jira key as text:

Related Jira issue: PROJECT-122

Later, when `PROJECT-122` exists in the ledger, run a reconciliation pass:

  • scan GitHub issue bodies and comments for Jira keys;
  • check whether each key exists in the ledger;
  • add or replace the GitHub issue link;
  • leave an audit trail in the issue or ledger.

That lets you migrate in practical batches without losing relationships.

Watch parent and epic fields

Jira parent and epic relationships are easy to miss because they are not always normal issue links.

Check fields such as:

  • `parent`;
  • `Epic Link`;
  • `IssueParentAssociation`;
  • custom fields used by your Jira instance.

Preserve the relationship explicitly:

  • the child GitHub issue should link to the parent GitHub issue;
  • the parent GitHub issue should list the child issues.

If you skip this, you can migrate all visible links and still lose useful planning structure.

Development references are separate

Jira's development panel is not the same as normal issue fields.

Branches, commits, and pull requests may live behind separate Jira dev-status endpoints or app-specific integration data. If your team used Jira with Bitbucket, GitHub, or another code host, check this before deleting tickets.

At minimum, preserve:

  • branch names;
  • pull request titles;
  • pull request status;
  • commit IDs when available;
  • old URLs, even if you plan to rewrite them later.

Old Bitbucket URLs may break after repository migration, but branch names and commit hashes are still useful. Keep the textual data even if the URL eventually changes.

Make deletion boring

Deletion should be a boring final step.

That means deletion needs a gate.

A ticket should not be delete-ready unless:

  • the Jira key exists in the ledger;
  • the GitHub issue exists;
  • the GitHub issue was read back successfully;
  • the pre-delete preservation marker is present;
  • comments were copied or deliberately summarized;
  • attachments were copied or explicitly not applicable;
  • linked issues are mapped or deliberately accepted;
  • parent and epic relations are preserved;
  • development references are preserved;
  • the ticket belongs to the intended Jira project;
  • the ticket appears in an explicit delete list.

In pseudocode:

function blockersFor(entry) {
  const blockers = [];

  if (!entry.github_issue) {
    blockers.push("Missing GitHub issue");
  }

  if (!entry.pre_delete_preservation?.github_comment_posted) {
    blockers.push("Missing pre-delete preservation comment");
  }

  if (entry.copied?.attachments?.copied === false) {
    blockers.push("One or more Jira attachments were not copied");
  }

  if (entry.linked_jira_issues?.some((link) => link.required && !link.github_url)) {
    blockers.push("Required linked Jira issue has not been migrated");
  }

  if (!entry.copied?.history?.compact_summary_copied) {
    blockers.push("Missing compact history summary");
  }

  return blockers;
}

for (const entry of ledger.entries) {
  entry.delete_blockers = blockersFor(entry);
  entry.delete_ready = entry.delete_blockers.length === 0;
}

The delete list should be generated from the ledger, not hand-written.

Verify deletes with readback

A successful Jira delete normally returns HTTP 204.

That is not enough.

After deleting, read the Jira issue again. Only mark it deleted if Jira returns 404.

async function deleteAndVerifyJiraIssue(jiraKey) {
  const deleteResponse = await jiraDelete(`/rest/api/3/issue/${jiraKey}`);
  const verifyResponse = await jiraGet(`/rest/api/3/issue/${jiraKey}`);

  if (verifyResponse.status === 404) {
    return {
      jiraKey,
      deleted: true,
      deleteStatus: deleteResponse.status,
      verifyStatus: verifyResponse.status
    };
  }

  return {
    jiraKey,
    deleted: false,
    deleteStatus: deleteResponse.status,
    verifyStatus: verifyResponse.status,
    error: verifyResponse.json
  };
}

This catches real-world problems:

  • permission failures;
  • network failures;
  • parent tickets that still have subtasks;
  • already-deleted issues;
  • incorrect project scopes.

In my migration, a few parent tickets initially failed because Jira said they still had subtasks. The subtasks were also in the migration and were deleted later in the run. Retrying the parents after the subtasks were gone worked cleanly.

Next time, I would explicitly delete subtasks before parent issues, or collect those parent failures and retry them at the end.

Keep rate limits explicit

Large migrations create many writes:

  • GitHub issue creation;
  • GitHub comments;
  • GitHub issue closing;
  • GitHub release asset uploads;
  • Jira deletes;
  • verification reads.

Do not rely on accidental pacing.

Keep the official GitHub REST API rate limit documentation and Jira Cloud platform rate limiting documentation close while tuning the migration. Both APIs expose rate-limit headers, and Jira can return Retry-After on 429 responses.

Make write delays and retry settings explicit:

GH_WRITE_DELAY_MS=5000
GH_RETRY_DELAY_MS=300000
GH_MAX_ATTEMPTS=6

The exact values depend on your batch size and API limits. The principle is more important than the numbers: slow and verified is better than fast and uncertain.

Useful script structure

I split the migration scripts by responsibility:

jira-fetch-batch
jira-generate-github-issues
github-create-issues
jira-download-attachments
github-upload-attachments
github-post-attachment-comments
github-post-predelete-comments
github-readback
migration-reconcile
migration-generate-delete-list
jira-delete-and-verify
github-rewrite-jira-links

Each script should be restartable. If it writes to GitHub, add markers so it can detect already-posted comments. If it deletes from Jira, require an explicit delete list and verify every key.

Avoid a single "migrate everything and delete" script. That is convenient until something goes wrong.

Dry-run everything destructive

The delete script should default to dry-run mode.

For example:

JIRA_PROJECT_KEYS=PROJECT \
JIRA_DELETE_KEYS_PATH=delete-keys.txt \
JIRA_DELETE_RESULTS_PATH=delete-dry-run-results.json \
node jira-delete-and-verify.js

Then require an explicit flag for deletion:

EXECUTE_DELETE=1 \
JIRA_PROJECT_KEYS=PROJECT \
JIRA_DELETE_KEYS_PATH=delete-keys.txt \
JIRA_DELETE_RESULTS_PATH=delete-results.json \
node jira-delete-and-verify.js

Also guard the project key. A delete list for `PROJECT-123` should not be able to delete `OTHER-123`.

Final audit before cancelling Jira

Before cancelling Jira, run a final read-only audit.

Do check:

  • all expected Jira projects have zero remaining issues;
  • every ledger entry is marked deleted from Jira;
  • every migrated GitHub batch has the expected issue count;
  • attachment preservation comments exist;
  • GitHub release attachment archives still exist;
  • no migration scripts contain secrets;
  • a full Jira backup exists if you need an administrative fallback.

A project-by-project Jira audit is safer than an unbounded search, because Jira Cloud may reject unbounded JQL queries.

The rough shape:

const projects = await jiraGet("/rest/api/3/project/search?maxResults=100");

for (const project of projects.json.values) {
  const issues = await searchJira(`project = ${project.key} ORDER BY key`);
  console.log({
    project: project.key,
    name: project.name,
    remainingIssues: issues.length
  });
}

Only after that kind of audit would I cancel the Jira subscription.

Checklist

Here is the practical checklist I would use again.

Before migrating

  • Make a full Jira backup.
  • Decide the exact project and batch scope.
  • Create a ledger before creating GitHub issues.
  • Decide target GitHub repositories.
  • Decide how Jira statuses and resolutions map to labels.
  • Decide how active and future sprints map to GitHub milestones.

During migration

  • Create one GitHub issue per Jira issue.
  • Close GitHub issues that represent resolved Jira tickets.
  • Preserve original Jira status, resolution, priority, type, and timestamps.
  • Copy comments as comments.
  • Preserve attachments outside Jira.
  • Preserve parent, epic, and linked-issue relationships.
  • Preserve useful development references.
  • Add a compact pre-delete history summary.
  • Read GitHub issues back through the API.
  • Reconcile readback against the ledger.

Before deleting Jira tickets

  • Generate the delete list from the ledger.
  • Confirm the delete list contains only the intended project keys.
  • Confirm every delete-ready ticket has zero blockers.
  • Dry-run the delete script.
  • Execute deletion only with an explicit flag.
  • Verify every deleted Jira key returns 404.
  • Update the ledger with delete results.

After deletion

  • Re-run Jira project counts.
  • Keep the ledger permanently.
  • Keep GitHub attachment release assets.
  • Run link-rewrite passes when additional Jira keys become mapped.
  • Remove or rewrite stale Jira browse links in GitHub issues.

Using Codex to supervise the migration

One thing that made this migration much more manageable was running the scripts through Codex instead of treating them as a one-shot import tool.

That does not mean letting an AI delete tickets unsupervised. The useful part was the opposite: Codex made it easier to supervise every step, inspect results, improve the scripts, and keep the migration disciplined.

It helped in a few practical ways:

  • turning raw Jira history into compact summaries;
  • checking whether migrated GitHub issues were readable by humans;
  • spotting missing attachment, parent, or linked-issue data;
  • improving issue titles and labels instead of blindly copying old tracker fields;
  • generating safer retry and readback checks after failures;
  • keeping the ledger, delete blockers, and verification results aligned.

That changed the result. A normal export/import often leaves you with a technically migrated backlog that still feels messy: stale titles, noisy descriptions, missing context, awkward labels, and closed work that is hard to understand later.

With Codex supervising the process, the migrated tickets could become better than a raw copy. Old Jira comments and changelog events were summarized into useful history. GitHub issue bodies were shaped for future readers. Labels and statuses were made consistent with the new workflow. The migration became a cleanup step, not just a transport step.

It did run for a long time, and it burned a lot of tokens. In my case, the migration depleted the Pro 20x plan. Still worth it. The benefit was that unexpected cases could be detected, fixed, and resumed instead of silently turning into bad migrated tickets.

Another benefit came after the migration. Once everything lived in GitHub Issues, it became much easier to run bulk checks or bulk actions across the new issue set. Again, rate limits still matter, but working against one source of truth is a lot easier than juggling Jira exports and partial imports.

The important rule is that Codex should help operate the process, not replace the verification gate. Add enough safeguards. Every destructive step still needs explicit scope, dry-runs, readback, and a ledger that proves what happened.

Final thought

Migrating away from Jira is not really about Jira.

It is about moving the source of truth without losing trust in the old source. Creating GitHub issues is the easy part. Knowing when it is safe to delete the Jira tickets is the hard part.

For me, the answer was: not after export, not after import, and not after a script says "done".

Only after readback verification proves the useful data exists somewhere else.

Now time to get all open tickets on GitHub resolved.

Keep reading

Latest from these topics

Recent posts that share topics with this article.

My personal tips for working from home

My personal tips for working from home

Remote working and working from home are completely two different concepts. Remote working is working from home, but also coworking spaces…

Read My personal tips for working from home