-
Notifications
You must be signed in to change notification settings - Fork 15
Jira Connector Developer Guide
Everything Jira-specific about includes/integrations/JiraProvider.php. Shipped as #946, corrected against live Jira in #953, and given the inbound half β reading comments back β in #954.
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.
The analyst-facing page is Issue trackers (Jira).
β Proven against real Jira Cloud on 2026-08-02. A ticket was escalated, the issue was created (
KAN-6), a status change flowed back through the poll, and β after #956 β a comment written onKAN-6in Jira arrived on the linked ticket as an internal note. Connection test, project listing, issue-type listing, create, read-back, batch refresh, comment search and comment read have all now run against a live site.
β οΈ The one path still never run live is comment push (send_note_to_tracker, #949). Outbound is now the untested direction.
Colour key: π provider Β· π§ shared rule Β· π§ͺ tests
| π¨ | File | What it does |
|---|---|---|
| π | includes/integrations/JiraProvider.php |
The whole connector β both flavours, outbound and inbound |
| π§ | includes/integrations/IssueTrackerProvider.php |
The contract it implements |
| π§ | includes/integrations/IssueDoc.php |
Supplies toAdf() and toWikiMarkup()
|
| π§ͺ | tests/integrations/run.php |
Β§5 (outbound) and Β§8 (comments back) β no live Jira needed |
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=β¦ |
| Comment bodies | ADF coming back too | wiki markup, a plain string |
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.
- If
credentials['flavour']is stored, use it. - Otherwise guess from the host β
*.atlassian.netis always Cloud, Data Center never is. -
testConnection()settles it properly by calling/myselfand, 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.
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.
Mapping arrived in #957 and this held: it was a settings screen and a lookup, with no change to this file beyond adding listPriorities() for the dropdown. createIssue()'s $fields argument merges straight into the payload, which is exactly what the mapped priority uses. The rule remains never guess, always be told β an admin states that our High means Jira's Highest.
β οΈ A rejected priority falls back to creating the issue without one, never failing the escalation β implemented inintegrationsEscalate(), not here, because it is policy rather than protocol. Losing a priority is cosmetic; losing the escalation because somebody renamed a priority on one project is not.β οΈ listPriorities()returns the SITE's priorities, but Jira applies them per project through priority schemes β so a name listed there can still be rejected by a particular project. That is precisely why the fallback exists.
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.
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.
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/searchno longer exists on Cloud. Atlassian removed it (CHANGE-2046) in favour of/rest/api/3/search/jql. Data Center's v2/searchis 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.
pollChanges() is the inbound half. The engine-side pipeline is on the engine page Β§7d; what follows is only what Jira forced.
A JQL search asks which of our watched issues changed, and only those are read for comments. On a quiet day that is one search and no comment reads at all. Scoping to the watch list rather than "everything that changed" matters for the same reason it does in fetchIssues(): an unscoped query on a busy site returns thousands of issues nobody here has ever linked to.
id in (10042,10043) AND updated >= -90m
An absolute JQL date is interpreted in the Jira user's timezone, not UTC. A server in one zone and a Jira account in another would then silently miss β or silently re-read β a window's worth of comments on every single poll, and it would look like nothing was wrong. Relative minutes have no timezone at all.
This is the same species of bug as the removed /search endpoint: correct-looking code, wrong assumption about somebody else's system, invisible until it has been wrong for weeks.
IssueDoc is the shared document every provider renders from; ADF is Atlassian's format and nobody else's. Putting an ADF reader in core would make it carry one tracker's vocabulary, so adfToText() sits in the connector alongside renderDoc() β the same split, in the other direction.
It is deliberately lossy: the job is "an analyst can read what the dev said", not a faithful round trip. Notes are plain text and the issue is one click away on the pill. Three node types need special handling because they carry their label in attrs rather than in a text child β mention, emoji and inlineCard β and recursing blindly drops them mid-sentence. Links keep their href, because "see here" with the URL discarded is worse than useless in a note.
parseComment() returns the author in the same shape testConnection() does β accountId on Cloud, username on Data Center β and it is flavour-aware for exactly that reason. Tests pin both halves.
π΄ It is NOT used to drop comments, and must not be. The original design dropped anything authored by our own account as "our echo". That assumes the API token belongs to a service account that never types; in reality the token owner is usually a human who comments in Jira, so their own comments were silently swallowed β which is precisely what happened on the first live run here. Echo suppression is by comment id instead. Full reasoning on the engine page Β§7d.
The value is still captured because the events guard-by-id cannot cover (edits, attachments, field changes) will need it, and because a per-connection opt-in setting may want it.
Sections 5 and 8 of tests/integrations/run.php β none needing a Jira site (227 assertions across the whole suite):
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.
Β§8 adds the inbound half on the same rig: ADF-to-text for every node type, the flavour-aware author identity, the relative-JQL window and its cap, the first-poll-imports-nothing rule, and that a non-numeric id is rejected before it can reach a query.
Verified by deliberate breakage: forcing renderDoc() to always return ADF fails exactly the two Data Center rendering assertions.
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_identityfromtestConnection() - 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.
β That validation has now happened β see the Azure DevOps connector guide. The contract held: the second connector needed no schema change and no core change of its own. Everything in the first list above did prove Jira-specific; everything in the second did apply.
Two decisions on this page are now known to be Jira-only, and must not be copied:
-
Relative JQL dates. Jira reads an absolute date in the user's timezone, which forced us onto
updated >= -90m. Azure DevOps honours an explicit UTCZ, so its connector uses absolute timestamps and is more precise for it. Copying either across would be a bug. -
author_identitybeing flavour-dependent (accountId vs username) is an Atlassian quirk; Azure DevOps has one GUID everywhere. What is general is that the value must match whattestConnection()returns, or every comment we write is re-imported.
One more thing worth knowing before connector #3: the four status categories were sized for Jira. Azure DevOps has five, and the extra one turned out to be a judgement rather than a mapping β so it became a per-connection setting. Expect the next tracker to have its own awkward state too, and resist widening the closed set to accommodate it.
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, the batch/paging logic, the relative-vs-absolute JQL date decision, or where author_identity comes from in either flavour (Β§7 β echo suppression compares it against testConnection(), and a silent mismatch is a comment loop).
The generic contract is documented on External Issue Trackers β Developer Guide, which carries the same note.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)