Skip to content

Jira Connector Developer Guide

Ed Mozley edited this page Aug 2, 2026 · 7 revisions

Jira Connector β€” Developer Guide

Everything Jira-specific about includes/integrations/JiraProvider.php. Shipped as #946, corrected against live Jira in #953.

The generic machinery β€” the contract, IssueDoc, the tables, the company guard β€” is on External Issue Trackers β€” Developer Guide. Read that first. This page is the worked example: it exists so that whoever writes the GitHub or Azure DevOps connector can see which decisions were forced by the contract and which were forced by Jira.

βœ… Proven against real Jira Cloud on 2026-08-02. A ticket was escalated, the issue was created (KAN-6), and a status change made in Jira flowed back to the ticket through the poll. Connection test, project listing, issue-type listing, create, read-back and batch refresh have all now run against a live site.


1. πŸ“ The files involved

Colour key: πŸ”Œ provider Β· 🧠 shared rule Β· πŸ§ͺ tests

🎨 File What it does
πŸ”Œ includes/integrations/JiraProvider.php The whole connector β€” both flavours
🧠 includes/integrations/IssueTrackerProvider.php The contract it implements
🧠 includes/integrations/IssueDoc.php Supplies toAdf() and toWikiMarkup()
πŸ§ͺ tests/integrations/run.php Β§5 of the suite β€” 72 assertions, no live Jira needed

2. Cloud and Data Center are not the same product

They share a name and very little else that matters here:

Jira Cloud Jira Data Center / Server
REST base /rest/api/3 /rest/api/2
Description format ADF (nested JSON) wiki markup (a string)
Auth Basic: email:api_token Bearer: Personal Access Token
Who we are accountId username (name)
Project list /project/search β€” paged /project β€” a plain array
Issue search /search/jql β€” the old /search was REMOVED /search β€” still current
Issue types /issue/createmeta/{key}/issuetypes /issue/createmeta?projectKeys=…

This is one provider with a flavour, not two providers. Two would have been ~80% duplicated, and the duplicated part is the part with the interesting logic.

How the flavour is decided

  1. If credentials['flavour'] is stored, use it.
  2. Otherwise guess from the host β€” *.atlassian.net is always Cloud, Data Center never is.
  3. testConnection() settles it properly by calling /myself and, if that fails, trying the other flavour before reporting bad credentials.

That fallback matters: a vanity domain in front of Cloud would otherwise look like a wrong password rather than a wrong guess.


3. ⚠️ Priority is deliberately not sent

buildCreatePayload() sets project, issue type, summary and description β€” and no priority, on purpose.

Jira priorities are defined per project with arbitrary names. Sending our "High" to a project whose scheme renamed everything to P1/P2/P3 fails the whole create with an error that gives no clue why. So priority travels as text inside the description instead.

The mapped version arrives later, and the rule is never guess, always be told β€” an admin states that our High means Jira's Highest. Note the connector already accommodates it: createIssue()'s $fields argument merges straight into the payload, so that work is a settings screen and a lookup, not a change here.

⚠️ When mapping does arrive, a rejected priority must fall back to creating the issue without one, not fail the escalation. Losing a priority is cosmetic; losing the escalation because somebody renamed a priority on one project is not.


4. Status mapping

Jira's statusCategory.key is the only stable thing in Jira's status model β€” those three keys are identical on every project on every site, which is exactly why we key off them and never off the status name.

Jira category key Our category
new todo
indeterminate in_progress
done done
anything else null β€” never guess

Jira has no cancelled category; a "Won't Do" lands in done. So STATUS_CANCELLED is simply unreachable from Jira, and that is correct rather than a gap.

An unknown key yields null rather than a guess. The raw name is still stored, so the panel shows something truthful while no automation fires on it.


5. Error handling

Jira reports problems two ways: errorMessages for general failures, and an errors map for field-level ones. extractError() unpacks both.

This is not polish. The single most common real failure is a project whose screen scheme demands a field we did not send:

{"errors": {"customfield_10010": "Epic Link is required"}}

Surfacing that as "customfield_10010: Epic Link is required" is the difference between an admin fixing it in a minute and an undiagnosable "HTTP 400". 401/403/404 get plain-English equivalents, and a non-JSON body (an HTML error page from a proxy) is stripped of tags rather than dumped raw.


6. Two robustness decisions worth copying

A successful create survives a failed follow-up read. createIssue() reads the issue back so the panel is not blank, but if that read fails the issue still exists in Jira β€” so the link is kept with an unknown status rather than the whole escalation being thrown away and the analyst re-raising a duplicate.

Batch reads are one JQL call. fetchIssues() overrides the contract's loop-one-at-a-time default with id in (…), chunked at 100 because JQL has a practical length limit. This is the status-refresh cron's hot path. Non-numeric ids are rejected before they can reach the query β€” Jira ids are always numeric, so anything else is either a bug or an attempt at JQL injection.

⚠️ /rest/api/3/search no longer exists on Cloud. Atlassian removed it (CHANGE-2046) in favour of /rest/api/3/search/jql. Data Center's v2 /search is unaffected, so the endpoint is flavour-aware like everything else here.

This was found by running the poll against a real Jira, which answered "The requested API has been removed." No amount of re-reading the code would have caught it β€” the code was correct against the API as it used to be. Both halves are now pinned by tests.

The lesson generalises: a connector's correctness depends on somebody else's API, and that changes without warning. When a call starts failing, suspect the endpoint before suspecting the logic.

A failure on every chunk is a connection failure. fetchIssues() swallows one bad chunk so a single flaky page does not lose the rest β€” but if every chunk fails it rethrows, because returning [] is indistinguishable from "none of those issues exist". Without that, the removed-endpoint error above would have surfaced as a cheerful "checked 1, changed 0" and nobody would have noticed for weeks.

Also: listProjects() walks Cloud's paging to the end rather than taking the first 50. A silently truncated list looks exactly like a bug ("my project isn't in the dropdown"). And subtask issue types are excluded, because they need a parent and are never a valid escalation target.


7. πŸ§ͺ Tests

Section 5 of tests/integrations/run.php β€” about 72 assertions, none needing a Jira site:

php tests/integrations/run.php

FakeJira subclasses JiraProvider and stubs httpRequest() with a queue of canned [code, body] pairs, recording what was requested. Protected logic is exposed through thin pub* wrappers. That makes all of this provable offline: flavour detection and the fallback, URL building including a trailing slash on the site URL, the ADF-vs-wiki choice, payload shape, summary flattening and truncation, the status map, error extraction, project paging, subtask exclusion, batch JQL, id rejection, and that Cloud sends Basic auth while Data Center sends a Bearer header.

Verified by deliberate breakage: forcing renderDoc() to always return ADF fails exactly the two Data Center rendering assertions.


8. If you are writing the next connector

What was forced by Jira, and should not be copied blindly:

  • a project + issue type target β€” GitHub needs only a repo
  • numeric issue ids β€” GitHub's are numeric but GitLab uses per-project iids
  • the Cloud/Data Center flavour split β€” most trackers are SaaS-only
  • ADF β€” nothing else uses it

What was forced by the contract, and applies to you too:

  • map states onto the four categories, and refuse to guess on an unknown one
  • return account_identity from testConnection()
  • keep payload building and parsing separate from HTTP, so it stays testable
  • never let a successful write be discarded by a failed follow-up read
  • surface the provider's own error text; "HTTP 400" helps nobody

⚠️ Validate the contract against Azure DevOps, not GitHub β€” see the engine page Β§10 for why.


9. πŸ“„ Keeping this page honest

Update this page in the same commit if you change the flavour detection or its fallback, the status mapping table, the decision not to send priority, the error-extraction behaviour, or the batch/paging logic.

The generic contract is documented on External Issue Trackers β€” Developer Guide, which carries the same note.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally