Back to blog

Automate Django I18n: Integration with Jira

2026-04-24 12 min read
Automate Django I18n: Integration with Jira

Meta description: Manual translation tracking in Jira breaks fast. Here’s a practical Django workflow to create Jira tickets from .po changes and automate i18n.

You ship the feature, run makemessages, open locale/fr/LC_MESSAGES/django.po, and realize the actual work hasn't started yet. Now you need to track dozens of new strings, assign review, keep placeholders intact, and stop translators from working off stale diffs. If your current integration with Jira is “copy a few msgids into a ticket and hope nobody misses the rest,” you already know how releases drift.

The ugly part is that Jira usually sits in the middle of engineering work, but translation work still gets managed off to the side. A spreadsheet appears. Somebody pastes .po snippets into Slack. Someone else creates a parent ticket with “translate French and German” in the summary, which tells nobody what changed. A week later, compilemessages passes, but half the strings never got reviewed.

The Translation Tracking Problem No One Talks About

Many teams don't fail at Django i18n because gettext_lazy is hard. They fail because the work after extraction is messy.

A typical release branch has a diff like this:

python manage.py makemessages --locale=fr --locale=de
git diff locale/

You get new entries across multiple locales. Some are empty msgstr. Some are changed source strings that need re-translation. Some have placeholders that can break your app if they come back wrong.

#: billing/forms.py:18
#, python-format
msgid "Invoice for %(name)s is overdue"
msgstr ""

#: templates/account/profile.html:42
msgid "Save changes"
msgstr ""

What's missing is a documented pattern for this exact workflow. Atlassian’s own Jira integrations overview covers lots of integration categories, but the documented examples don't really cover localization and i18n ticket creation from translation events. That gap is why so many Django teams end up inventing their own brittle process.

Manual tracking breaks in boring ways

The failures aren't dramatic. They're repetitive.

Practical rule: If a string change doesn't create a reviewable artifact in Git and a trackable item in Jira, it will be missed during a busy release.

If you're dealing with raw .po files every week, it's worth reviewing how gettext files behave in practice. This short guide on working with gettext PO files in Django is a useful refresher before you automate around them.

Designing Your Jira Project for Localization

Bad automation on top of bad Jira design just creates faster confusion. Fix the schema first.

Jira supports deep customization, and the Atlassian Marketplace has over 3,000 apps according to Alpha Serve’s overview of Jira connectors. The point for this workflow isn't to install more apps. It's to use Jira’s issue types, fields, and workflow states in a way that maps cleanly to translation work.

A five-step guide on how to design and configure a Jira project specifically for localization workflows.

Use a dedicated issue type

Don't dump localization into generic tasks. Create an issue type like Localization Task or Translation Review.

That gives you room to:

A clean workflow for most Django teams looks like this:

Status Meaning Who owns it
To Do New untranslated or changed string detected Developer or automation
In Translation Strings are being translated or updated Translator or engineer
In Review .po diff is under review in Git or staging Reviewer
Done Merged, compiled, and checked Engineer

Add fields that prevent follow-up questions

Every Jira ticket should answer, at minimum, “what changed,” “where,” and “for which locale.”

Use custom fields for these:

You don't need all of these on day one. You do need enough structure that your script can fill fields consistently.

Tickets should be actionable without opening Slack, searching Git history, or asking “which string are we talking about?”

Keep grouping rules boring

There are two common patterns.

One issue per string works when review needs to be very granular. It also creates ticket noise fast.

One issue per locale file change is often a more effective approach. Group strings by locale and path, then include the changed entries in the description. That's usually enough detail without turning Jira into a second message catalog.

What doesn't work is one giant “French translation” ticket for an entire release. It hides changed strings, fuzzy strings, and review state behind a single checkbox.

Connecting to the Jira API with a Personal Access Token

Before you automate issue creation, get auth working in isolation. If auth is flaky, everything above it becomes impossible to debug.

For Jira Cloud workflows like this, authenticating with a personal access token is the baseline. IBM’s DevOps Velocity documentation notes that a personal access token is mandatory for Jira Cloud in that setup and can prevent up to 70% of common automation authentication failures. It also notes that starting with a pilot and using approval controls until you have 95% confidence can reduce unintended updates by 80%, which is the right mindset for a new integration with Jira. Here’s the IBM documentation on integrating Jira.

Screenshot from https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html

Store secrets outside your repo

Use environment variables locally and your CI secret store in GitHub Actions, GitLab CI, or whatever runs your pipeline.

Never hardcode a token into a script like this:

JIRA_TOKEN = "paste-token-here"

Use:

export JIRA_BASE_URL="https://your-team.atlassian.net"
export JIRA_EMAIL="dev@example.com"
export JIRA_TOKEN="your-token"

If your team is still sorting out board design while you wire this up, TestDriver’s guide to best practices for structuring Jira and Azure boards is worth reading before you automate ticket creation into a messy workflow.

Test auth before you build ticket logic

This sanity check catches bad credentials, wrong base URLs, and permission problems early.

import os
import requests

jira_base = os.environ["JIRA_BASE_URL"]
jira_email = os.environ["JIRA_EMAIL"]
jira_token = os.environ["JIRA_TOKEN"]

resp = requests.get(
    f"{jira_base}/rest/api/3/myself",
    auth=(jira_email, jira_token),
    headers={"Accept": "application/json"},
    timeout=30,
)

resp.raise_for_status()
print(resp.json()["displayName"])

Run it with:

python scripts/check_jira_auth.py

If this fails, don't move on to issue creation. Fix scopes, project permissions, and credentials first.

Scripting Jira Issue Creation from PO File Changes

This is the part that replaces copy-paste work.

The flow is boring by design. Parse .po files, find untranslated or changed entries, build a stable payload, create or update a Jira issue, and label it by locale. Planview’s guide notes that careful field mapping matters, and that piloting on one locale first can produce an 85% first-pass success rate while avoiding field mismatch problems that account for 40% of sync failures in their benchmark framing. Here’s the Planview guide to Jira integration mapping and rules.

A hand-drawn diagram illustrating a Python script that reads a .po file via polib to create Jira issues.

What the script should detect

For most Django projects, start with these rules:

Here's a working script that creates one Jira issue per locale file when it finds untranslated entries.

import os
from pathlib import Path

import polib
import requests

JIRA_BASE_URL = os.environ["JIRA_BASE_URL"].rstrip("/")
JIRA_EMAIL = os.environ["JIRA_EMAIL"]
JIRA_TOKEN = os.environ["JIRA_TOKEN"]
JIRA_PROJECT_KEY = os.environ["JIRA_PROJECT_KEY"]
JIRA_ISSUE_TYPE = os.environ.get("JIRA_ISSUE_TYPE", "Task")

PO_FILES = [
    Path("locale/fr/LC_MESSAGES/django.po"),
    Path("locale/de/LC_MESSAGES/django.po"),
]

def parse_untranslated_entries(po_path: Path):
    po = polib.pofile(str(po_path))
    entries = []

    for entry in po:
        if entry.obsolete:
            continue
        if not entry.msgstr.strip():
            entries.append({
                "msgid": entry.msgid,
                "occurrences": entry.occurrences,
                "flags": list(entry.flags),
            })

    return entries

def build_description(po_path: Path, entries: list[dict]) -> str:
    lines = [
        f"*Locale file:* `{po_path}`",
        f"*Untranslated entries:* {len(entries)}",
        "",
        "h3. New strings",
        "",
    ]

    for item in entries[:20]:
        lines.append(f"* msgid: {{noformat}}{item['msgid']}{{noformat}}")
        if item["occurrences"]:
            source_refs = ", ".join(
                f"{path}:{lineno}" for path, lineno in item["occurrences"]
            )
            lines.append(f"  source: `{source_refs}`")
        if item["flags"]:
            lines.append(f"  flags: `{', '.join(item['flags'])}`")
        lines.append("")

    if len(entries) > 20:
        lines.append(f"...and {len(entries) - 20} more entries in the file.")

    return "\n".join(lines)

def create_jira_issue(summary: str, description: str, locale_code: str):
    payload = {
        "fields": {
            "project": {"key": JIRA_PROJECT_KEY},
            "summary": summary,
            "description": description,
            "issuetype": {"name": JIRA_ISSUE_TYPE},
            "labels": ["localization", f"lang-{locale_code.lower()}"],
        }
    }

    response = requests.post(
        f"{JIRA_BASE_URL}/rest/api/3/issue",
        auth=(JIRA_EMAIL, JIRA_TOKEN),
        headers={
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        json=payload,
        timeout=30,
    )
    response.raise_for_status()
    return response.json()

def main():
    for po_path in PO_FILES:
        if not po_path.exists():
            continue

        locale_code = po_path.parts[1]
        untranslated = parse_untranslated_entries(po_path)

        if not untranslated:
            print(f"No untranslated entries in {po_path}")
            continue

        summary = f"[L10N] Translate {locale_code} strings in {po_path.name}"
        description = build_description(po_path, untranslated)
        issue = create_jira_issue(summary, description, locale_code)
        print(f"Created {issue['key']} for {po_path}")

if __name__ == "__main__":
    main()

Install dependencies with:

pip install polib requests

Why this shape works

The script groups work by locale file. That's easier to review than one issue per string and easier to route than one issue for an entire release.

A realistic project layout still matters here:

locale/fr/LC_MESSAGES/django.po
locale/de/LC_MESSAGES/django.po

If you want more control than shelling out around .po files, the TranslateBot Python API docs are a good example of how to keep translation logic scriptable and reviewable inside your own tooling.

Later, you can enrich the payload with custom fields for Target Language, Source File Path, or a parent Epic. Get creation working first.

A quick walkthrough helps if you want to compare your implementation against another Jira automation flow:

What usually breaks

Most failures come from one of these:

Don't start with all locales. Pick one locale, one project, one issue type, and one repo. Make the boring path work first.

Wiring Everything into Your CI CD Pipeline

A local script is useful. A script that runs every time translatable strings change is better.

For Django, the most reliable pattern is: extract messages, diff locale files, then create Jira work items if new untranslated entries appear. Keep the pipeline read-only until you're confident in the output. After that, let it create tickets automatically.

A GitHub Actions workflow that runs on push

name: localization-jira-sync

on:
  push:
    branches:
      - main

jobs:
  create-jira-localization-issues:
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install gettext
        run: |
          sudo apt-get update
          sudo apt-get install -y gettext

      - name: Install Python dependencies
        run: |
          python -m pip install --upgrade pip
          pip install Django polib requests

      - name: Generate message files
        run: |
          python manage.py makemessages --locale=fr --locale=de

      - name: Create Jira issues for untranslated strings
        env:
          JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
          JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
          JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
          JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }}
          JIRA_ISSUE_TYPE: Task
        run: |
          python scripts/create_jira_l10n_issues.py

That assumes your repo already has Django settings available in CI. If makemessages depends on optional apps or environment settings, mirror the minimum runtime config your project needs.

Keep CI predictable

A few rules save a lot of pain:

You can also split extraction from ticket creation. Some teams prefer one workflow to update .po files and another to create Jira issues only when locale diffs are present.

If your CI job can create issues, it can also create garbage. Keep approval and review in the loop until the payload shape is stable.

If you want a packaged example of how CI-driven translation commands are wired, the TranslateBot CI usage docs are a practical reference for integrating translation steps into a normal Django pipeline.

Advanced Integration Patterns and Troubleshooting

Once basic ticket creation works, you can make the integration with Jira more useful without turning it into an internal platform project.

Add a return path from Jira to your repo

One-way sync handles extraction well. Bidirectional flow is where teams usually get ambitious and break things.

A safe pattern is to trigger a CI job when a Jira issue moves to Done, then run validation steps:

You can also group localization tasks under a feature Epic. That gives product and engineering a better view of release readiness without burying translation work in unrelated boards.

Track translation coverage, not just ticket counts

Test management tools in Jira often expose metrics like Automation Coverage percentages per user story, as shown in Testomat’s Jira statistics examples. The useful idea here isn't test coverage itself. It's the reporting model.

You can adapt that pattern into a Translation Coverage % report by language. For example, count translated entries against total entries in a locale file and surface that in Jira dashboards or an external report. Stakeholders care less about how many tickets exist and more about whether fr is ready for release.

Debug the common failures first

Most production problems are boring:

Problem Usually caused by Fix
401 or 403 from Jira bad token or missing permission verify auth with /myself, then project rights
Duplicate issues no search or dedupe key store issue keys by locale file and branch
Wrong locale labels inconsistent directory naming normalize fr, de, pt_BR before label creation
Noisy reports one ticket per trivial change group by file or feature

If you're comparing tooling patterns outside your stack, it can help to explore different integrations just to see how other teams model status sync, approvals, and event triggers across systems.

Fuzzy strings deserve their own handling. Treat them as review work, not the same thing as empty msgstr.

Your Action Plan for an Automated i18n Workflow

Do this on a feature branch, not in main.

You don't need a huge platform effort to fix this. You need a repeatable path from .po diff to Jira issue to reviewed merge.

If you're trying to make room for this kind of maintenance work in an already crowded week, pieces on how teams reclaim valuable time with AI automation can be a useful reminder that boring workflow fixes are often the ones that pay back first.

Do the first pass with one locale, one project, and one branch. If that run creates clean tickets and your reviewers can act on them without asking follow-up questions, keep going.


If you want to skip the custom glue code and keep your translations inside a normal Django workflow, TranslateBot is built for that. It translates .po files and model fields from your codebase, preserves placeholders and HTML, fits into CI, and keeps the work in Git instead of another portal.

Stop editing .po files manually

TranslateBot automates Django translations with AI. One command, all your languages, pennies per translation.