Making Jira and GitHub agree without anyone updating both
Subtitle: Two tools, two sources of truth, one tax: keeping them in sync by hand. So I built a bridge that mirrors Jira onto every PR — and quietly flags the work that's stuck.
The Self-Driving Repo · Part 3 — Integration

Every team that uses Jira and GitHub pays the same small, constant tax: keeping the two of them telling the same story. The ticket is "In Review" but the PR is merged. The PR is open but the ticket is "Done." Which one do you believe? Usually neither, so you go ask the person — and now three people are doing status archaeology instead of building.
I didn't want a heavyweight integration or a paid app. I wanted the PR to know things about its ticket automatically: what release it's targeting, what labels it carries, whether it's been marked Done — and, the part that turned out most useful, whether it's been Done for too long without merging.
The problem
The mismatch between issue tracker and code host shows up as three distinct annoyances:
1. No release visibility on the PR. Which version is this fix shipping in? That lives in Jira's "Fix Version" field, invisible from GitHub. Reviewers and release managers had to cross-reference. 2. No size signal. Is this a two-line fix or a 40-file refactor? You can't triage review effort from the PR list. 3. The "Done but not merged" black hole. A ticket gets marked Done in Jira, but the PR sits unmerged for days — blocked on QA, on a dependency, on someone's attention. Nobody notices until the release manager goes looking.
The idea
One workflow, triggered both on PR events and on a schedule, that for each open PR:
- pulls the ticket ID from the branch name,
- calls the Jira REST API for that issue's status, fix versions, labels, and resolution date,
- mirrors that metadata onto the PR as prefixed labels,
- adds an automatic size label from the file count,
- and raises a "Slow PR" flag when a ticket has been Done for more than three days but the PR is still open.
The scheduled trigger is the unlock. PR events only fire when code changes — but Jira status changes on its own timeline (someone moves a card hours later). Running twice a day on weekdays catches those async changes without anyone touching the PR.
on:
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '0 9 * * 0-4' # 9:00 — weekdays
- cron: '0 13 * * 0-4' # 13:00 — weekdays
How it works

Branch name → Jira issue
Same trick as the rest of the system: the ticket ID is encoded in the branch, so a regex is all the "integration" you need to start.
const jiraId = branch.match(/([A-Z]+-\d+)/)?.[1]; // e.g. PROJ-123
Then a plain authenticated REST call — no SDK, no app install. Jira uses Basic auth with an API token, base64-encoded client-side:
const auth = Buffer.from(`${email}:${apiToken}`).toString('base64');
const res = await fetch(
`${JIRA_BASE}/rest/api/3/issue/${jiraId}?fields=status,resolutiondate,labels,fixVersions`,
{ headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' } }
);
const issue = await res.json();
Mirroring with prefixes
The fix versions and Jira labels become GitHub labels, but namespaced so they're visually distinct and easy to clean up — fix: for release versions, jira: for tags:
for (const v of issue.fields.fixVersions) {
await ensureLabel(`fix:${v.name}`); // fix:1.42.0
await addLabel(`fix:${v.name}`);
}
The non-obvious half is removal. A bridge that only adds labels rots fast — drop a fix version in Jira and the stale fix: label lingers on the PR forever. So the sync is reconciling, not append-only: it computes the set of fix:/jira: labels the PR should have from Jira, and removes any prefixed label that's no longer backed by Jira. Add and subtract, every run.
Size labels from the file count
Cheap, and genuinely useful for triage. Pull the file list, bucket it:
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number });
const n = files.length;
if (n <= 2) setExclusive('tiny PR'); // a glance
else if (n < 10) setExclusive('small PR'); // a coffee
else clearSizeLabels(); // block out real time
setExclusive adds the right bucket and removes the other, so a PR is never both "tiny" and "small" at once — the labels stay mutually exclusive as the PR grows.
The "Slow PR" flag (the sleeper feature)
This is the one people actually thanked me for. When Jira says the ticket is Done, the workflow checks how long it's been done. If the resolution date is more than three days old and the PR is still open, it flags it:
const DONE_GRACE_MS = 3 * 24 * 60 * 60 * 1000;
if (status === 'done') {
addLabel('Done');
removeLabel('wip');
const age = Date.now() - new Date(resolutionDate).getTime();
if (age > DONE_GRACE_MS) addLabel('Slow PR'); // done, but stuck
}
A "Slow PR" label is a tiny thing that surfaces a real, expensive problem: work that's finished but not shipping. That's value sitting on the shelf — the most wasteful state in the whole pipeline, and the easiest to miss because everyone thinks it's done. Now it's visible on the board without anyone running a report.
When the ticket flips back from Done (reopened, more work), the same logic removes the Done and Slow PR labels. Reconcile, don't accumulate.
What it bought us
- Release scope is visible on every PR.
fix:1.42.0right on the card — no Jira spelunking to answer "what's in the next build?" - Reviewers triage by size at a glance. "tiny PR" gets a quick pass; a 40-file change gets scheduled.
- Stuck-but-done work stops hiding. The "Slow PR" flag turned an invisible cost into a visible one we could actually clear.
- Nobody updates two tools by hand. The PR reflects Jira within hours, automatically — roughly 5 fewer "is this merged yet?" pings a week.
Gotchas & trade-offs
- It's eventually consistent, not real-time. A status change shows up at the next scheduled run, not instantly. For this use case that's fine; if you need instant, you need Jira webhooks, which is a bigger build.
- Naive PR-body edits are fragile. Auto-ticking a "quality approved" checkbox by string replacement breaks silently if the template wording changes. Keep templates stable.
- No pagination cap on the PR loop. Iterate every open PR each run and a repo with hundreds of open PRs will eventually brush the job timeout. Batch or shard before you get there.
- Two-way "sync" is really one-way mirroring. This reflects Jira onto GitHub, not the reverse. True bidirectional sync invites loops and conflicts — I deliberately didn't build it. Decide your source of truth and mirror from it.
- Token scope is a real risk. A Jira API token in CI reads your tracker. Scope it minimally and store it as a secret, never in the workflow.
Takeaway
You don't need a marketplace integration to make two tools agree — a branch-name regex, one REST call, and a reconciling label sync get you most of the value. And while you're bridging, look for the flag that exposes a hidden cost. The labels were nice. "Slow PR" — surfacing finished work that wasn't shipping — was the actual win.
Next: what happens to all those open PRs the moment something lands on master.
The complete workflow
Here is the full, genericized workflow — drop it into .github/workflows/ and replace the placeholders (your-org, the PROJ project key, <@DISCORD_USER_ID>, the example team, and the secret names) with your own.
.github/workflows/jira-status-labels.yml
name: PR Labels
on:
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '0 13 * * 0-4' # At 1 PM UTC, Sunday-Thursday
- cron: '0 9 * * 0-4' # At 9 AM UTC, Sunday-Thursday
workflow_dispatch:
permissions:
pull-requests: write
issues: write
jobs:
manage-pr-labels:
runs-on: ubuntu-slim
steps:
- name: Get PRs to process
id: get_prs
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
let prs = [];
if (context.eventName === 'pull_request') {
// Single PR from event
prs = [context.payload.pull_request];
} else {
// Scheduled run or manual dispatch - get all open PRs
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100
});
}
core.setOutput('prs', JSON.stringify(prs.map(pr => ({
number: pr.number,
branch: pr.head.ref,
labels: pr.labels.map(l => l.name)
}))));
- name: Apply PR labels
uses: actions/github-script@v7
env:
JIRA_BASE_URL: https://your-org.atlassian.net
JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
with:
script: |
const prs = JSON.parse('${{ steps.get_prs.outputs.prs }}');
const owner = context.repo.owner;
const repo = context.repo.repo;
const DONE_LABEL = 'Done';
const SLOW_PR_LABEL = 'Slow PR';
const TINY_PR_LABEL = 'tiny PR';
const SMALL_PR_LABEL = 'small PR';
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const TINY_FILE_THRESHOLD = 2;
const FILE_THRESHOLD = 10;
// Ensure labels exist
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({ owner, repo, name, color, description });
console.log(`Created label: ${name}`);
} else {
throw e;
}
}
}
const WIP_LABEL = 'wip';
await ensureLabel(DONE_LABEL, '0e8a16', 'Jira task is marked as Done');
await ensureLabel(SLOW_PR_LABEL, 'fbca04', 'Jira task was Done more than 3 days ago');
await ensureLabel(TINY_PR_LABEL, '00d4aa', 'PR has 1-2 changed files');
await ensureLabel(SMALL_PR_LABEL, '0e8a16', 'PR has fewer than 10 changed files');
for (const pr of prs) {
const branchName = pr.branch;
const prNumber = pr.number;
const currentLabels = pr.labels;
// --- Small PR Label Logic ---
// Get the list of files changed in this PR
const { data: files } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100
});
const fileCount = files.length;
console.log(`PR #${prNumber}: ${fileCount} file(s) changed`);
const hasTinyPRLabel = currentLabels.includes(TINY_PR_LABEL);
const hasSmallPRLabel = currentLabels.includes(SMALL_PR_LABEL);
const isTiny = fileCount <= TINY_FILE_THRESHOLD;
const isSmall = !isTiny && fileCount < FILE_THRESHOLD;
async function removeLabelSafe(label) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
console.log(`PR #${prNumber}: Removed "${label}" label`);
} catch (e) {
if (e.status !== 404) throw e;
}
}
if (isTiny) {
if (!hasTinyPRLabel) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [TINY_PR_LABEL] });
console.log(`PR #${prNumber}: Added "${TINY_PR_LABEL}" label (${fileCount} file(s))`);
}
if (hasSmallPRLabel) await removeLabelSafe(SMALL_PR_LABEL);
} else if (isSmall) {
if (!hasSmallPRLabel) {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [SMALL_PR_LABEL] });
console.log(`PR #${prNumber}: Added "${SMALL_PR_LABEL}" label (${fileCount} files)`);
}
if (hasTinyPRLabel) await removeLabelSafe(TINY_PR_LABEL);
} else {
if (hasTinyPRLabel) await removeLabelSafe(TINY_PR_LABEL);
if (hasSmallPRLabel) await removeLabelSafe(SMALL_PR_LABEL);
}
// --- Jira Status Label Logic ---
// Extract Jira ID from branch name (e.g., PROJ-13451)
const jiraIdMatch = branchName.match(/([A-Z]+-\d+)/);
if (!jiraIdMatch) {
console.log(`PR #${prNumber}: No Jira ID found in branch "${branchName}"`);
continue;
}
const jiraId = jiraIdMatch[1];
console.log(`PR #${prNumber}: Found Jira ID ${jiraId}`);
// Call Jira API to get issue details
const jiraUrl = `${process.env.JIRA_BASE_URL}/rest/api/3/issue/${jiraId}?fields=status,resolutiondate,labels,fixVersions`;
const auth = Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64');
let jiraData;
try {
const response = await fetch(jiraUrl, {
headers: {
'Authorization': `Basic ${auth}`,
'Accept': 'application/json'
}
});
if (!response.ok) {
console.log(`PR #${prNumber}: Failed to fetch Jira issue ${jiraId} (status ${response.status})`);
continue;
}
jiraData = await response.json();
} catch (e) {
console.log(`PR #${prNumber}: Error fetching Jira issue ${jiraId}: ${e.message}`);
continue;
}
const status = jiraData.fields?.status?.name;
const resolutionDate = jiraData.fields?.resolutiondate;
const jiraLabels = jiraData.fields?.labels || [];
const fixVersions = (jiraData.fields?.fixVersions || []).map(v => v.name);
console.log(`PR #${prNumber}: Jira status = "${status}", resolutionDate = "${resolutionDate}", labels = ${JSON.stringify(jiraLabels)}, fixVersions = ${JSON.stringify(fixVersions)}`);
// Sync Jira Fix Versions to PR (prefixed with "fix:" to distinguish from other labels)
const FIX_VERSION_PREFIX = 'fix:';
for (const version of fixVersions) {
const ghLabelName = `${FIX_VERSION_PREFIX}${version}`;
// Ensure the label exists in GitHub
await ensureLabel(ghLabelName, 'e6e6fa', `Jira Fix Version: ${version}`);
// Add label to PR if not already present
if (!currentLabels.includes(ghLabelName)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [ghLabelName]
});
console.log(`PR #${prNumber}: Added "${ghLabelName}" label from Jira Fix Version`);
}
}
// Remove fix: labels that are no longer on the Jira issue
const currentFixLabels = currentLabels.filter(l => l.startsWith(FIX_VERSION_PREFIX));
const expectedFixLabels = fixVersions.map(v => `${FIX_VERSION_PREFIX}${v}`);
for (const currentFixLabel of currentFixLabels) {
if (!expectedFixLabels.includes(currentFixLabel)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: currentFixLabel
});
console.log(`PR #${prNumber}: Removed "${currentFixLabel}" label (no longer in Jira Fix Versions)`);
} catch (e) {
if (e.status !== 404) throw e;
}
}
}
// Sync Jira labels to PR (prefixed with "jira:" to distinguish from other labels)
const JIRA_LABEL_PREFIX = 'jira:';
for (const jiraLabel of jiraLabels) {
const ghLabelName = `${JIRA_LABEL_PREFIX}${jiraLabel}`;
// Ensure the label exists in GitHub
await ensureLabel(ghLabelName, 'c5def5', `Jira label: ${jiraLabel}`);
// Add label to PR if not already present
if (!currentLabels.includes(ghLabelName)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [ghLabelName]
});
console.log(`PR #${prNumber}: Added "${ghLabelName}" label from Jira`);
}
}
// Remove jira: labels that are no longer on the Jira issue
const currentJiraLabels = currentLabels.filter(l => l.startsWith(JIRA_LABEL_PREFIX));
const expectedJiraLabels = jiraLabels.map(l => `${JIRA_LABEL_PREFIX}${l}`);
for (const currentJiraLabel of currentJiraLabels) {
if (!expectedJiraLabels.includes(currentJiraLabel)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: currentJiraLabel
});
console.log(`PR #${prNumber}: Removed "${currentJiraLabel}" label (no longer in Jira)`);
} catch (e) {
if (e.status !== 404) throw e;
}
}
}
const isDone = status?.toLowerCase() === 'done';
const hasDoneLabel = currentLabels.includes(DONE_LABEL);
const hasSlowPRLabel = currentLabels.includes(SLOW_PR_LABEL);
const hasWipLabel = currentLabels.includes(WIP_LABEL);
if (isDone) {
// Add "Done" label if not present
if (!hasDoneLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [DONE_LABEL]
});
console.log(`PR #${prNumber}: Added "${DONE_LABEL}" label`);
}
// Update PR body to check the quality team checkbox
const { data: fullPr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber
});
let prBody = fullPr.body || '';
const qualityCheckboxUnchecked = '- [ ] The `quality` team approved at least 1 platform *`(Android, IOS)`*.';
const qualityCheckboxChecked = '- [x] The `quality` team approved at least 1 platform *`(Android, IOS)`*.';
if (prBody.includes(qualityCheckboxUnchecked)) {
prBody = prBody.replace(qualityCheckboxUnchecked, qualityCheckboxChecked);
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
body: prBody
});
console.log(`PR #${prNumber}: Checked quality team approval checkbox`);
}
// Remove "wip" label if present
if (hasWipLabel) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: WIP_LABEL
});
console.log(`PR #${prNumber}: Removed "${WIP_LABEL}" label (task is Done)`);
} catch (e) {
if (e.status !== 404) throw e;
}
}
// Check if done for more than 3 days
if (resolutionDate) {
const resolvedAt = new Date(resolutionDate);
const now = new Date();
const msSinceDone = now - resolvedAt;
const daysSinceDone = msSinceDone / (24 * 60 * 60 * 1000);
if (msSinceDone > THREE_DAYS_MS) {
if (!hasSlowPRLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [SLOW_PR_LABEL]
});
console.log(`PR #${prNumber}: Added "${SLOW_PR_LABEL}" label (resolved ${Math.floor(daysSinceDone)} days ago)`);
}
}
}
} else {
// Remove labels if task is no longer Done
if (hasDoneLabel) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: DONE_LABEL
});
console.log(`PR #${prNumber}: Removed "${DONE_LABEL}" label (status changed)`);
} catch (e) {
if (e.status !== 404) throw e;
}
}
if (hasSlowPRLabel) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: SLOW_PR_LABEL
});
console.log(`PR #${prNumber}: Removed "${SLOW_PR_LABEL}" label (status changed)`);
} catch (e) {
if (e.status !== 404) throw e;
}
}
}
}
console.log('✅ PR labels check completed');