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.
- Strings get lost: A developer updates
msgid, but no ticket gets updated. - Context disappears: The translator sees text, not file path or source reference.
- Review gets skipped: Jira says “done,” but nobody recompiled and checked the UI.
- Release branches diverge: Translators work from yesterday’s
.pofile.
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.

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:
- route tickets differently
- report on language-specific work
- separate translation QA from feature delivery
- keep board filters sane
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:
- Target Language:
fr,de,pt_BR - Source File Path:
locale/fr/LC_MESSAGES/django.po - String Count: useful when one issue groups related entries
- Git Ref: branch or commit SHA
- Source Location:
billing/forms.py:18 - Msgid Hash: optional, but useful for dedupe
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.

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.

What the script should detect
For most Django projects, start with these rules:
- Empty
msgstr: create a translation ticket fuzzyflag present: create a review ticket or add comment- Changed
msgid: treat as new translation work - Obsolete entries: ignore for Jira creation
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:
- Bad issue type name: Jira expects the exact configured issue type.
- Wrong project permissions: auth works, issue creation doesn't.
- Overstuffed description fields: dumping the whole file into one ticket gets noisy.
- Duplicate tickets: no dedupe key, no search before create.
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:
- Run on one branch first: use a feature branch or staging branch while validating payloads.
- Limit locales early: start with one language to inspect ticket quality.
- Fail loudly on auth errors: don't swallow
requestsexceptions. - Avoid auto-closing work: ticket closure should follow merge and review, not extraction alone.
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:
- check whether the
.podiff is merged - run
compilemessages - run UI smoke tests for the affected locale
- comment back on the issue if compilation fails
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.
- Create the Jira shape first: add a localization issue type, workflow states, and a couple of custom fields.
- Generate a token and test auth: run the
/myselfrequest before writing any automation logic. - Add one script to your repo: parse one locale file, find empty
msgstr, and create one Jira issue. - Pilot on one locale: inspect summaries, descriptions, labels, and permissions before adding more languages.
- Wire it into CI: run
makemessages, then run the issue-creation script with secrets from your CI system. - Decide how to group work: one issue per file is usually the best default.
- Handle review separately: fuzzy strings and translation QA shouldn't be hidden inside extraction tickets.
- Add reporting later: once the pipeline is stable, surface translation coverage by locale.
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.