Two-Way Sync Between Jira and GitHub Issues Without Losing Your Mind
TL;DR — Two-way sync is solvable but you need a state store you control — never rely on the source systems to remember which way an edit came from. / Loop prevention is the bug you’ll spend the most time on; design for it upfront with a sentinel field, not after. / “Eventually consistent” is the right model. Don’t promise users real-time.
Two-way sync between Jira and GitHub Issues sits in a particular category of engineering problem: it sounds simple, you can build a v0 in a day, and then you spend the next three months patching edge cases. The good news is that the edge cases are largely the same across implementations, so the patterns generalize.
This post is the playbook I’d give a team starting from scratch today. It assumes the n8n setup from the self-hosting post and familiarity with the Jira REST API v3 patterns. I’ll cover the architecture, the data model, the loop-prevention strategy, and the field-mapping problem that nobody warns you about.
I’ll deliberately keep this focused on Jira and GitHub Issues. The same patterns apply to Linear or any other system, but the mechanical details shift.
Architecture: don’t sync, project
The first instinct when building two-way sync is to wire two webhooks together. Jira webhook updates GitHub, GitHub webhook updates Jira. Direct sync. This works for the demo and explodes the moment two edits land within the webhook round-trip window.
The pattern that scales is to treat both Jira and GitHub as projections of a central state. n8n receives events from both sides, normalizes them into a canonical representation in a Postgres table, and then projects changes outward. Both systems are sinks, neither is the source of truth.
Jira webhook ----+
|
v
GitHub webhook -> [n8n] -> [Postgres state] -> [n8n projector] -> Jira API
\-> GitHub API
The state table looks something like:
CREATE TABLE synced_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jira_key TEXT UNIQUE,
github_repo TEXT NOT NULL,
github_issue_number INT,
title TEXT NOT NULL,
body TEXT NOT NULL,
state TEXT NOT NULL, -- open, closed, in_progress, etc
priority TEXT,
labels TEXT[],
assignee_email TEXT,
jira_version INT NOT NULL DEFAULT 1,
github_version INT NOT NULL DEFAULT 1,
last_jira_event_at TIMESTAMPTZ,
last_github_event_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (github_repo, github_issue_number)
);
CREATE INDEX idx_synced_issues_updated ON synced_issues (updated_at DESC);
The jira_version and github_version columns are the basis for last-write-wins conflict resolution. They increment on every successful projection. If a webhook arrives with a stale view of the entity, the projector knows to merge rather than overwrite.
Loop prevention: the sentinel pattern
The classic two-way-sync infinite loop: n8n receives a Jira event, updates GitHub, GitHub fires a webhook back to n8n, which updates Jira, which fires another webhook, which… You get the idea. By the time you notice, you’ve burned through your API rate limit on both sides and the issue has 47 identical comments.
There are three ways to prevent this. The bad one, the OK one, and the good one.
Bad: time-window deduplication. “If we updated this issue in the last 30 seconds, ignore the next webhook for it.” This is fragile. A legitimate concurrent edit gets dropped. The window is always wrong.
OK: actor filtering. Both sides give you an actor on the webhook event. If the actor is your bot account, skip. This works as long as you have exactly one bot account, you remember to filter consistently, and you never make a manual edit from the bot account.
Good: sentinel fields with version vectors. When the projector writes to GitHub, it includes a sentinel — a tag in the issue body, a label, or a property — that says “this update originated from Jira version 7.” When the GitHub webhook fires for that update, the n8n handler checks the sentinel, sees that the corresponding Jira version is unchanged, and skips.
In practice, I encode this as a hidden HTML comment at the end of the body:
<!-- sync-version: jira=7 github=12 source=jira -->
The receiving handler parses the trailing line, compares against the state table, and decides whether the event represents a real user edit or a sync echo. A user editing the body has to either preserve the comment (most people do, it’s invisible) or the comment vanishes — which the handler also detects and treats as a real edit.
const SYNC_RE = /<!-- sync-version: jira=(\d+) github=(\d+) source=(\w+) -->/;
function parseSyncFooter(body) {
const match = body.match(SYNC_RE);
if (!match) return null;
return {
jiraVersion: parseInt(match[1], 10),
githubVersion: parseInt(match[2], 10),
source: match[3],
};
}
function isSyncEcho(incomingEvent, dbRecord) {
const footer = parseSyncFooter(incomingEvent.body || '');
if (!footer) return false;
// If the version vector in the body matches what we projected, this is an echo.
return (
footer.jiraVersion === dbRecord.jira_version &&
footer.githubVersion === dbRecord.github_version &&
footer.source !== incomingEvent.source
);
}
It’s not pretty but it’s robust. I’ve run this in production for going on a year now and the loop count is exactly zero.
Field mapping: the part everyone underestimates
Jira and GitHub have wildly different field models. Jira has issue types, components, fix versions, sprints, custom fields, and a hierarchy with epics. GitHub Issues has labels, milestones, assignees, and a flat structure.
You can’t sync everything. You shouldn’t try. The mapping I’ve found durable:
| Jira | GitHub | Direction |
|---|---|---|
| Summary | Title | both |
| Description (plain) | Body | both |
| Status | State (open/closed) + label | Jira -> GH primarily |
| Priority | Label (priority:p1 etc) |
Jira -> GH primarily |
| Labels | Labels | both |
| Assignee (email) | Assignee (username) | requires lookup table |
| Comments | Comments | both, with author attribution |
What I deliberately don’t sync:
- Custom fields. They’re project-specific and they break the abstraction. Keep them on the Jira side.
- Sprints and epics. GitHub’s structure doesn’t support them cleanly. Use GitHub Projects if you need that, separately.
- Attachments. Both sides allow them, the storage stories differ, and the bandwidth cost is real. Link, don’t copy.
The assignee mapping deserves its own callout. Jira identifies users by accountId, GitHub by login. You need a lookup table — an actual database table or a YAML file in your config — that maps one to the other. People change names, leave the company, and create personal vs work accounts. Plan for it.
CREATE TABLE user_mappings (
jira_account_id TEXT PRIMARY KEY,
jira_email TEXT NOT NULL,
github_login TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
When a user can’t be mapped — common for external reporters — fall back to a generic bot account on the receiving side and credit the original author in the comment body.
Comment sync, carefully
Comments are the most user-visible part of any sync. A few rules I’ve found important:
- Attribute the original author in the comment body. “Jane Doe wrote on Jira” as the first line. Otherwise every comment looks like it was made by your bot account and the audit trail is useless.
- Don’t sync edits. Comments often get edited within minutes of posting. Syncing every edit means flicker, noise, and confusion. Sync the comment once on first creation; treat edits as terminal.
- Don’t sync deletes. This one’s surprisingly controversial. A user deleting a comment on one side rarely means they want it gone on both. Add a soft-delete marker if it really matters, but I default to keeping the comment in place.
- Rate limit yourself. A bulk import of 200 comments shouldn’t fire 200 API calls in one second. Throttle to something like 5 per second per direction.
Eventual consistency, not real-time
The single most useful expectation to set with your users: this is eventually consistent, not real-time. Edits propagate “within a minute” in normal operation. Sometimes longer if a webhook fails and the reconciliation job picks it up. Don’t promise “instant” sync — you can’t deliver it reliably, and the disappointed-users path is worse than the slightly-slower-than-expected path.
A nightly reconciliation job that re-pages both sides and patches any drift is essential. The GitHub REST API issue endpoints support since= filtering, which makes the diff cheap. On the Jira side, JQL updated > -24h does the equivalent.
Common Pitfalls
- GitHub
closedis final-ish. Closing a GitHub issue and then reopening it via the API works, but some integrations (linked PRs, project board automations) react to the close event in ways that don’t reverse cleanly. Avoid bouncing state from a sync handler. - Jira transitions require valid IDs. You can’t just set
status = "In Progress". You have to call the transitions endpoint with a transition ID that’s valid for the issue’s current state. Cache the workflow per project on startup. - GitHub mentions trigger notifications. If you sync a Jira comment that says “@alice please look at this” verbatim, you’ve just notified whoever owns the
aliceusername on GitHub. Strip or escape@mentionsin comment text — or remap them through your user lookup table. - Webhook ordering is not guaranteed. A create + update can arrive in reverse order in rare cases. Your handler needs to be robust to processing an update for an entity that doesn’t exist yet — usually by creating the entity on demand.
- Rate limits are per-token. The bot account hits both sides constantly. Monitor your remaining-quota headers and consider splitting load across multiple bot accounts if you’re a high-volume team.
Wrapping Up
Two-way sync is one of those projects where the architecture decisions you make in the first week determine whether you spend the next year happy or miserable. Pick a central state store, version your projections, design for loop prevention from the start, and set “eventually consistent” expectations with your users.
The next post in this series shifts gears to a more interactive use case: building Slack-driven approval flows for backlog operations, so that the humans in the loop don’t have to leave the chat tool they’re already in.