Jira ↔ Priority.vote sync walkthrough
This guide builds a one-way Jira → Priority.vote sync end-to-end: starting from a fresh Priority.vote account, ending with a script that creates a Priority.vote initiative for each Jira issue and keeps the two in sync when the Jira side changes.
The script is written in Python for illustration; the API is language-agnostic and any HTTP client works.
Prerequisites
- A Priority.vote account with at least one backlog you own.
- A Jira project and a personal token for it (this guide assumes you can already call the Jira REST API).
- Python 3.10+,
httpx, anduuidavailable in your environment.
Step 1 — Create the PAT
Follow Personal access tokens:
- Name:
jira-sync - Scopes:
write:initiatives - Restrict to: the single backlog you want Jira to write into
- Expiry: 1 year
Copy the value and put it in a secret store (PRIORITY_VOTE_PAT for this guide).
Step 2 — Identify the backlog id
Pull your backlogs with the token and pick the right one. Output is paginated; pass ?per_page=100 if you have more than 50.
GET /api/v1/backlogs HTTP/1.1
Authorization: Bearer pv_pat_…
Save the id in an env var (PRIORITY_VOTE_BACKLOG_ID).
Step 3 — Sync loop
import httpx
import os
import time
BASE = "https://priority.vote/api/v1"
PAT = os.environ["PRIORITY_VOTE_PAT"]
BACKLOG_ID = os.environ["PRIORITY_VOTE_BACKLOG_ID"]
HEADERS = {"Authorization": f"Bearer {PAT}", "Accept": "application/json"}
SOURCE = "jira"
def upsert_initiative(issue):
"""Look up by external_ref; PATCH if exists, POST if not."""
lookup = httpx.get(
f"{BASE}/backlogs/{BACKLOG_ID}/initiatives",
params={"external_source": SOURCE, "external_ref": issue["key"]},
headers=HEADERS,
timeout=10,
)
payload = {
"title": issue["fields"]["summary"],
"description": issue["fields"].get("description") or None,
"effort": issue["fields"].get("customfield_effort", 1),
"external_source": SOURCE,
"external_ref": issue["key"],
}
idem = f"jira-sync-{issue['key']}-{issue['fields']['updated']}"
headers = {**HEADERS, "Idempotency-Key": idem}
if lookup.status_code == 404:
r = httpx.post(
f"{BASE}/backlogs/{BACKLOG_ID}/initiatives",
json=payload,
headers=headers,
timeout=10,
)
else:
existing = lookup.json()
r = httpx.patch(
f"{BASE}/backlogs/{BACKLOG_ID}/initiatives/{existing['id']}",
json=payload,
headers=headers,
timeout=10,
)
handle(r)
def handle(r):
if r.status_code in (200, 201):
return
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", "5"))
print(f"rate limited; sleeping {wait}s")
time.sleep(wait)
return
if r.status_code == 422:
body = r.json()
print(f"validation error: {body}")
return
if r.status_code in (401, 403):
raise SystemExit(f"auth problem: {r.status_code} {r.text} — check PAT")
if r.status_code == 409:
print(f"conflict (likely external_ref race) — will retry next tick")
return
raise RuntimeError(f"unexpected {r.status_code}: {r.text}")
Call upsert_initiative for each Jira issue you want to mirror. Run the script every few minutes (cron, systemd-timer, GitLab schedule — your choice).
Step 4 — Handle the corner cases
- Issue archived in Jira. Decide your policy: archive in Priority.vote too (PATCH
status: "archived") or leave alone. - Effort missing in Jira. Default to 1. Or pull from a custom field.
- Jira issue renamed (same key). The
external_refstays stable — your PATCH ontitlesimply updates the existing initiative. - Jira issue key changes (rare). Treat as a delete + create.
What the logbook will show
Every PATCH and POST writes a logbook entry tagged via 'jira-sync' token. You can audit exactly which initiative changes came from the adapter vs which came from a human stakeholder.
Where to go next
- Two-way sync (push Priority.vote changes back to Jira) — read General integration for the polling and error-handling baselines, then mirror this guide in reverse.
- Other sources: the same pattern works for any tracker. Pick an
external_sourcestring and the API will treat it as a separate uniqueness namespace per backlog. New sources must be added to the server registry in a TDR before they are accepted.