-
Notifications
You must be signed in to change notification settings - Fork 15
External Issue Trackers Developer Guide
How a FreeITSM ticket gets linked to an issue in someone else's tracker, and why the machinery underneath is deliberately not about Jira. Shipped as #945β#957. Proven against a real Jira Cloud site on 2026-08-02 β including, in #956, a design fault that only a live account could have exposed.
Jira is the first connector and the only one currently written, but almost nothing here is about Jira. This page is the contract; everything Jira-specific lives on Jira Connector β Developer Guide, which doubles as the worked example for anyone adding GitHub, GitLab or Azure DevOps.
The analyst-facing page is Issue trackers (Jira).
β οΈ Status: it is two-way for comments, one-way for everything else. You can configure a tracker, escalate a ticket by hand or by workflow rule, see the issue on the ticket, and β since #954 β read the comments people write in Jira as internal notes. What still does not exist is webhooks (so everything arrives on the poll's schedule, not instantly) and thetracker.*triggers that would let a workflow react to an inbound event. Everything inbound depends on the poll cron: unscheduled, nothing ever changes. Β§9 has the full picture.
Colour key: π§ shared rule Β· π provider Β· ποΈ schema Β· π§ͺ tests Β· π docs
| π¨ | File | What it does |
|---|---|---|
| π§ | includes/integrations/IssueDoc.php |
One description or comment, built once, rendered four ways (Β§4) |
| π§ | includes/integrations/IssueTrackerProvider.php |
The abstract contract every tracker implements (Β§3) |
| π§ | includes/integrations/integrations.php |
The service: the provider registry, load a connection, escalate, refresh status, pull comments back β and the company guard (Β§6, Β§7, Β§7d) |
| π | includes/integrations/JiraProvider.php |
The first concrete connector β see its own page |
| βοΈ | workflow/includes/engine.php |
The two actions, the connection lookup and argBool() (Β§7a) |
| βοΈ |
cron/integration_poll.php, docs/integration-poll-cron-setup.md
|
The poll β the only thing that refreshes status or brings comments back (Β§7, Β§7d). Analyst-facing setup: Scheduled tasks |
| β¨οΈ |
system/integrations/index.php, provider.php, .htaccess
|
The settings screen and its pretty URLs (Β§7b) |
| π |
api/integrations/list_connections.php, save_connection.php, test_connection.php, delete_connection.php
|
Connection CRUD, admin-gated |
| β¨οΈ | system/includes/areas.php |
The System landing card + its icon |
| β¨οΈ |
assets/js/inbox.js, tickets/index.php, assets/css/inbox.css
|
The pill in the Links strip and the escalate modal (Β§7c) β bump inbox.js?v=
|
| π | api/tickets/get_email_detail.php |
Adds tracker_links to the ticket payload |
| π | api/tickets/get_notes.php |
LEFT JOIN on analysts + attribution for imported notes (Β§7d) |
| π |
api/integrations/escalate_ticket.php, connections_for_ticket.php
|
The manual escalate + its company-filtered connection list |
| π |
api/integrations/tracker_options.php, get_mapping.php, save_mapping.php
|
The mapping screenβs data: what the tracker offers, and what we map to it (Β§7e) |
| π |
lang/en/tickets.php, lang/pt-BR/tickets.php
|
tickets.tracker.* |
| ποΈ |
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php
|
integration_connections, integration_links, integration_comment_map, integration_field_maps, their keys and indexes |
| π |
lang/en/system.php, lang/pt-BR/system.php
|
system.integrations.*, EN and pt-BR in the same commit |
| π§ͺ | tests/integrations/run.php |
252 assertions, none needing a live tracker (Β§8) |
| π |
CHANGELOG.local.md, this wiki |
logged as #945β#957 |
Note what is not there: no change to the tickets module at all. That is a consequence of escalation being a workflow action (Β§2), not an accident.
Three layers. The middle one is new; the outer two already existed.
WORKFLOW ENGINE (exists β workflow/includes/engine.php)
triggers Β· conditions Β· action registry Β· vars Β· dry run Β· step log
β escalate_to_tracker Β· send_note_to_tracker β built
β β² tracker.* triggers (V2, not built yet)
βββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββ
β INTEGRATION ENGINE (this page) β
β link registry Β· IssueDoc Β· status categories Β· β
β company guard Β· escalate service Β· status refresh β
βββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β IssueTrackerProvider (abstract)
βββββββββββββββββββββΌβββββββββββββββββββ¬βββββββββββββββββ
JiraProvider GitHubProvider GitLabProvider DevOpsProvider
(built) (later) (later) (later)
The bottom split is deliberately the same one as includes/messaging/MessagingProvider.php, which already puts Twilio and Meta Cloud behind a single contract while ingestion, ticket creation and the reply path stay ignorant of which is live. That pattern is proven in this codebase, so it was copied rather than reinvented.
The top connection is the important design decision. Escalation is a workflow action, not code in the tickets module β because Workflows already has triggers, conditions, {{ticket.subject}} substitution, dry run and an audit trail. "When a ticket becomes type Bug, raise it in Jira" is therefore a rule a user writes, not a feature we build.
The bet paid off exactly as intended: shipping escalation added not one line to the tickets module. Two entries in the action registry, two handlers, one lookup source.
This is the table to read before writing a connector. Generic means the engine relies on it and you must comply. Provider-specific means it is only how one tracker happens to work, and copying Jira's shape into a different tracker would be a mistake.
| Concept | Generic (the contract) | Provider-specific (illustrative) |
|---|---|---|
| Issue identity | every tracker has a stable id, and usually a human key | Jira's id is numeric, its key is OPS-412 and changes on project rename |
| Status | must map onto four categories: todo / in_progress / done / cancelled
|
Jira exposes statusCategory.key with three values and no cancelled |
| Body | build an IssueDoc, never a string |
Jira Cloud wants ADF; Data Center wants wiki markup |
| Auth | credentials are a JSON blob, encrypted at rest | Jira Cloud is Basic email:token; Data Center is a Bearer PAT |
| Identity of us |
testConnection() must return account_identity
|
Cloud gives accountId, Data Center gives a username |
| Target | opaque array passed to createIssue()
|
Jira needs project + issue type; GitHub needs only a repo |
| Optional features | declare via supports(); unsupported methods throw |
Jira supports everything, GitHub has no issue types at all |
Four abstract methods, because every tracker genuinely has them:
abstract public function createIssue(array $target, string $summary, IssueDoc $body, array $fields = []): array;
abstract public function fetchIssue(string $externalId): array;
abstract public function testConnection(): array;
abstract public function renderDoc(IssueDoc $doc);Everything else β addComment(), addAttachment(), verifyWebhook(), pollChanges(), listProjects(), listIssueTypes() β has a default that throws "not supported for this provider". That is copied from MessagingProvider and it matters: if optional methods were abstract, the contract would collapse to whatever the weakest tracker can do.
supports() answers the same question for the UI, which needs to know before it renders a field. A thrown exception is the wrong way for a settings screen to discover that GitHub has no issue types.
The default loops fetchIssue(), which is always correct. Providers that can read many issues in one call should override it, because the status-refresh cron is the hot path. One issue failing must not lose the rest.
Polling is scoped to the issues we actually hold links to, not to "everything that changed" β an unscoped poll on a busy tracker returns thousands of issues nobody here cares about. V4, where an issue raised in the tracker creates a ticket, is the case that will need an unscoped variant; it can pass an empty list plus a project scope then.
integrations.php requires encryption.php, ssl.php and tenancy.php itself rather than leaving it to the caller. It originally did not, which worked only because the first callers β the settings endpoints β happened to require them already. The workflow engine does not, and the escalate action fataled on decryptValue() the first time it ran for real. A shared service cannot assume anything about who called it.
Status names are per-project, per-workflow and renamed at will. Every decision in the system keys off status_category; status_name is stored for display only. This is the same lesson as tickets.merged_into_id β a merged ticket is identified by a column, never by a status called "Merged", because a user can rename it.
The problem: every tracker wants a different body format.
| Tracker | Format |
|---|---|
| Jira Cloud | ADF β Atlassian Document Format, a nested JSON document |
| Jira Data Center | wiki markup |
| GitHub / GitLab | Markdown |
| Azure DevOps | HTML |
If core built strings, there would be four sets of string-building that drift. So core builds a document:
$doc = (new IssueDoc)
->heading('Raised in FreeITSM')
->para('Ticket ', IssueDoc::link($url, 'SD-1042'), ' Β· Jane Doe (Acme Ltd)')
->rule()
->para($ticket['description']);β¦and asks for the shape it needs: toAdf(), toWikiMarkup(), toMarkdown(), toHtml(), toPlainText().
The renderers live in IssueDoc, not in the connectors. That is what makes a new tracker cheap β renderDoc() is usually one line returning an existing renderer, so adding GitHub means writing no renderer at all.
Deliberately small: heading, paragraph, link, bullet list, code block, rule. It is not a rich-text format. Anything bigger becomes a second thing that has to be kept safe against untrusted ticket text, and the house rule is that there is only ever one sanitiser (assets/js/safe-html.js) β so this stays small enough not to become a rival to it.
Text is always treated as plain text and escaped for the target format. A requester writing "use the * wildcard" must not produce italics; <script> must not survive into an Azure DevOps description. Never pass markup in and expect it to survive.
ADF rejects empty text nodes and empty paragraphs, and a malformed document 400s the whole request with an unhelpful error. So the builders refuse to create empty blocks at all: ->para('') adds nothing, a leading ->rule() is swallowed, and an entirely empty document still renders as one valid paragraph rather than an invalid empty one.
One configured tracker instance β a Jira site, a GitHub org. credentials is a JSON blob encrypted at rest with the same helper as messaging_channels.credentials; webhook_secret likewise.
Two columns deserve a note:
-
account_identityβ who our token authenticates as, captured at connection test.β οΈ It is deliberately not used to suppress comments β see Β§7d and #956; doing so swallowed the token owner's own comments. It is kept for the inbound events guard 1 cannot cover (edits, field changes) and for a possible per-connection setting. -
ingress_modeβwebhookorpoll. See Β§9.
The spine: one row per "our work item β their issue".
-
entity_typeis polymorphic from day one (ticket/problem/change) even though onlyticketis ever written. It costs nothing now and would be a migration later. -
external_idis the provider's stable id, not the key βPROJ-412becomesOPS-412when a project is renamed, so the key is display only. -
UNIQUE (connection_id, external_id, entity_type, entity_id)β one issue may legitimately link to two tickets, never twice to the same one.
One row per comment that has crossed in either direction. direction is in (tracker β note) or out (note β tracker); local_note_id is nullable because a comment pushed by a workflow has text behind it but no note.
β οΈ UNIQUE (link_id, external_comment_id)is not an optimisation, it is the guarantee. The service reads the map before importing, but that read is check-then-act: two overlapping cron runs would both pass it and both post the note. The unique key is what makes the second writer lose. Never drop it "because we check first".
This is also why the row is written before the note rather than after β see Β§7d.
This is the part most likely to be got wrong, because tenant_id means three different things in three kinds of table. See Multi-Tenancy β Developer Guide Β§1 for the full table; the short version for this module:
integration_connections.tenant_idNULL = shared across every company (an MSP's own Jira). Set = pinned to that one company (a client with their own Jira).
That is the connection shape β the same as mailboxes and messaging channels, and the opposite of tickets, where NULL means "the Default company's".
Consequences:
- The admin list is deliberately unfiltered. Someone configuring routing needs to see every connection at once.
-
Never scope it with
activeTenantFilter(). That treats NULL as Default-owned and would hide every shared connection from every client company. -
A read that returns credentials is the exception to "capabilities guard writes, not reads".
integrationsListConnections()returnshas_credentialsas a boolean and never the token. That is what makes an install-wide list defensible; without it, "deliberately unfiltered" would be a cross-company credential leak.
integrationsCompaniesCompatible(?int $entityTenantId, ?int $connectionTenantId, ?int $defaultTenantId): boolEscalation is workflow-driven, and a workflow's conditions are editable by anyone who can author workflows. The wiki is blunt that inbound company routing must never become a workflow rule β it is a hardcoded synchronous membrane. This is the outbound twin of exactly that rule: without it, one mis-scoped workflow escalates Acme's ticket content into Globex's Jira.
So the check lives in code nobody can edit, and runs before any network call and before any write.
- Shared connection β accepts anything.
- Pinned connection β accepts only its own company.
- A NULL work item is resolved to the Default company before comparing, so a Default ticket can still reach a Default-pinned connection.
- No resolvable owner + a pinned target β refuse, never guess.
- On a single-company install every call is trivially true β correct rather than a bypass, because there is nothing to leak to.
integrationsEscalate() is the one place an escalation happens. The workflow action and the manual button will both call it. There is deliberately no separate "automatic" implementation, because the guards would then exist twice and one copy would eventually drift.
Order of operations, which is itself the design:
- Schema gate β 2. validate β 3. load connection β 4. company guard β 5.
skip_if_linkedβ 6. dry-run returns here β 7. create the issue β 8. record the link.
Note that dry run returns before the network call. A workflow test that minted a real Jira issue would be an unacceptable surprise.
Every read goes through integrationsSchemaReady(). An install that has not run Database Verification has no tables, and an unguarded query would throw inside the ticket view. "Not set up" must look like "no linked issues", never like an error β a missing gate of exactly this kind once produced an empty inbox.
cron/integration_poll.php β the only way status ever changes today, since webhooks are V2. An install that never schedules it shows every issue frozen at the status it had when it was raised, which is why docs/integration-poll-cron-setup.md opens by saying so.
It honours each connection's own poll_interval_minutes, stamps last_poll_datetime even on failure (so a permanently broken connection is not retried every single run), and reports one broken tracker without abandoning the others. Same token + min-interval conventions as the webhook and SLA crons.
JiraProvider::fetchIssues() swallows a failed chunk so one bad page does not lose the rest β but if every chunk failed it rethrows, because returning [] is indistinguishable from "none of those issues exist" and the poll would otherwise print "checked 12, changed 0" while Jira had been down for a week. That distinction was found by a test, not by reading the code.
integrationsRefreshConnection() batches every link on a connection into one provider call and writes only rows that actually moved, so a quiet day costs one read and no writes. An issue missing from the batch leaves its cached value alone rather than blanking it.
Only a change of category is reported as an event. A rename from "In Progress" to "In progress" is not something to wake a requester for.
The half that makes the integration two-way: a developer asks "what were the repro steps?" in Jira and the analyst sees it on the ticket without opening Jira.
poll cron ββΆ integrationsPullComments()
β provider->pollChanges($watermark, $watchList)
β ββ JQL: which of OUR issues changed? (1 call)
β ββ read comments on just those (n calls)
βΌ
canonical comment_added events
β
βΌ integrationsApplyCommentEvent()
have we seen this comment id? β yes ββΆ drop
β no (this is the whole echo guard)
βΌ
map row (UNIQUE wins races) ββΆ internal note ββΆ back-fill note id
-
Always internal. A Jira comment is written by someone who does not know a customer might read it. It never reaches the requester unless an analyst decides to pass it on.
is_internal = 1, not configurable. -
The first poll imports nothing.
pollChanges(null, β¦)returns an empty array and makes no HTTP call at all; the run only writes the watermark. Without this, switching Accept updates on would tip a tracker's entire comment history onto tickets that closed months ago. -
Off by default.
integration_connections.inbound_enabledgates the whole path. Inbound writes to tickets, so a half-finished setup must not start posting β the same rule the messaging webhooks follow.
We push a note to Jira β the poll sees a new comment β it becomes a note β which pushes again β forever.
The guard is integration_comment_map, and it works by comment id. We record the id Jira returns for everything we push, so our own writing is recognised on the way back whoever appears to have written it. Its UNIQUE key makes that exact even under two overlapping cron runs.
That is the whole guard. It is enough, and it is the only one applied to comments.
The original design had a second guard: drop anything authored by account_identity, the account our token authenticates as. It shipped in #954 and broke on the first live run against a real Jira.
The token owner is usually also a human who comments in Jira. So their own comments were classified as our echo and dropped β silently, with the poll cheerfully reporting 1 seen, 0 imported (skipped: echo=1). On a small team that is not an edge case; it is everybody.
It was redundant as well as harmful. Guard 1 already covers every comment we create, by id. Identity only adds value for events guard 1 cannot see β edits, attachments, field changes β none of which we process.
integrationsCommentIsEcho() is therefore kept but not applied to comments. It stays reachable through integrationsCommentSkipReason()'s $suppressByAuthor flag so that:
- a per-connection "ignore comments from the connection's own account" setting is a wiring change rather than a rewrite, and
- the branch is exercised by tests rather than rotting.
β οΈ Do not restore it as a default. If you find yourself reaching for author-based suppression, the regression test "A comment from OUR OWN account is still imported" is there to stop you, and this section is why.
β οΈ An unknown author is never treated as ours either. A tracker that stopped sending an author must not silently swallow every comment β the failure has to be visible.
The general lesson, which is the reason this is written up at length: the guard was correct against the model of a service account posting on our behalf, and wrong against the reality of one person owning both the token and the keyboard. It could not have been caught by any test written from the same assumption β only by running it against somebody's real Jira.
An imported note has no FreeITSM author. api/tickets/get_notes.php used JOIN analysts, which silently dropped every such note β presenting as "Jira comments never arrive", with nothing in any log. It is a LEFT JOIN now, and the header falls back to the connection's name.
That join turned out to be hiding real data already: on the development install 10 of 17 existing notes were invisible, written by analysts whose rows no longer exist (#955). api/tickets/delete_analyst.php is a plain DELETE that reassigns nothing, so this is the normal fate of every note a person wrote once they leave β not an edge case. An inner join on an author is a data-loss bug waiting for someone to delete a user; api/v1/resources/tickets.php and the portal's reader had both already got this right, and only this endpoint had not.
The endpoint now returns an author_kind and the browser resolves the label, because the translations live there:
author_kind |
When | Shown as |
|---|---|---|
analyst |
the join resolved | their name |
tracker |
imported from an issue tracker | the connection's name ("Jira") |
former |
a real analyst_id with no row left |
Former analyst |
system |
analyst_id 0 and no tracker behind it |
System |
Collapsing those four into one "Unknown" throws away information the row actually holds β which of them a note is, is knowable in every case.
Attribution is written into note_text as well as resolved by that join, because plenty of readers take note_text directly β the REST API, the portal, the AI write-up. A note reading only "any update?", with no hint it came from a dev in Jira, is worse than no note.
integration_connections.last_poll_watermark, stamped from before the provider call so a comment posted mid-poll lands in the next window rather than the gap between them, and advanced only on success so a failed pull is retried rather than skipped past.
The provider then converts it to relative JQL minutes (updated >= -90m), never an absolute date β see the Jira page Β§10 for why that distinction is not cosmetic. The lookback is capped at 24 hours regardless of how stale the watermark is, which is the same "no backlog" rule as the first-poll case.
The V3 slice. One table, one screen, applied at escalation: what our values mean in the tracker.
integrationsEscalate()
target given by the caller? ββ yes ββΆ use it (mapping never overrides)
β no
βΌ
integrationsLoadMaps(connection)
β
project β dept:N βΈ tenant:N βΈ '*' βΈ null
issue_type β typeId βΈ '*' βΈ null
priority β priorityId βΈ null β no '*', deliberately
integrationsResolveProject() checks department, then company, then *. A team with its own board is a sharper signal than the company a ticket belongs to, so it wins. Returning null when nothing matches is the point: an escalation with no resolvable project must say so, never file the issue in whatever project happened to be first.
issue_type and project both honour a * row. priority does not. "Everything is a Task" is a reasonable thing for an admin to mean; "every priority is Highest" would quietly mark a dev team's whole backlog urgent. An unmapped priority simply travels as text in the description, exactly as it did before mapping existed. Two tests pin this, including a positive control so the assertion is about the wildcard rather than a broken lookup.
Jira priorities are per project, so a project whose scheme renamed Highest to P1 rejects our mapped value and 400s the whole create. integrationsEscalate() catches that, drops the priority, and retries once:
if (!isset($fields['priority']) || !integrationsLooksLikePriorityRejection($e->getMessage())) throw $e;Both halves of that condition matter. The retry is narrow on purpose β retrying on any failure could turn a genuine error into an issue nobody meant to raise β and the reason it is worth doing at all is that losing a priority is cosmetic while losing the escalation is not. The dropped value is written to integration_links.last_error so the admin can see the mapping needs attention rather than it being silent.
What it points at differs per map_type: a tenant or department for routing, a ticket type id, a priority id. So project routing namespaces it β tenant:5, dept:3, * β and one map_type covers both routing dimensions plus the fallback, instead of a second column that would be NULL most of the time.
β οΈ The ticket column isticket_type_id, nottype_id. The workflow engine's lookup key isticket.type_id, which is not the column name β an easy way to write a query that silently returns nothing.integrationsEntityRouting()has the correct name and a comment saying so.
It replaces whole map types at a time (a mapping the admin deleted has to disappear, and diffing rows is more code and more ways to be wrong), so it needs a transaction β but PDO throws "There is already an active transaction" on a nested beginTransaction(). A caller that wraps it in its own β a bulk import, a test harness β would fatal.
$ownsTransaction = !$conn->inTransaction();
if ($ownsTransaction) $conn->beginTransaction();Found by the harness that proved mapping end to end, not by review. Same lesson as this file requiring its own dependencies (Β§3): a shared service cannot assume anything about its caller.
api/integrations/tracker_options.php is the first thing ever to call listProjects() / listIssueTypes() over HTTP β until #957 they existed on the connector and were exercised only by tests, which is exactly why the workflow action asked admins to type a project key.
- Projects and priorities are site-wide, so they load once as real dropdowns.
- Issue types are per project, so they are loaded from the default routing row's project and offered as suggestions β another project on the same site may legitimately offer different ones.
- A value saved earlier that the tracker no longer offers stays selectable, so it cannot vanish silently on the next save.
- If the tracker is unreachable the row degrades to a free-text box rather than blocking the screen.
Two actions in WorkflowEngine::availableActions(), dispatched from executeAction():
| Action | What it does |
|---|---|
escalate_to_tracker |
Raise an issue from a ticket and record the link |
send_note_to_tracker |
Post a comment onto the issue a ticket is already linked to |
Plus one lookup source (integration_connection β integration.connection_id β the integration_connections table) feeding the connection dropdown, and argBool(), which did not exist because this is the engine's first 'type' => 'bool' arg.
The handlers do no safety checking of their own. The company guard lives in integrationsEscalate(), and a second copy would be a second copy to get wrong β especially since a workflow's args are editable by anyone who can author workflows.
-
skip_if_linkeddefaults ON. A status-change trigger fires repeatedly on the same ticket; without this, each firing hands the dev team another duplicate. -
Dry run cannot create an issue.
runInner()short-circuits beforeexecuteAction(), so this is true by construction rather than by the handler remembering to check. Do not add a dry-run branch to a handler β it would be dead code implying a guarantee that lives elsewhere. -
send_note_to_trackerskips rather than fails when a ticket has no link. "On status change, tell the dev team" will fire on plenty of tickets nobody escalated, and that is not an error.
Their valid values depend on which connection was chosen and must be fetched from the tracker's API, but the editor builds every dropdown once at page load and has no cascade mechanism. Rather than invent one, the admin types the key. Routing (V3) removes the need entirely.
This is the target-arity problem in miniature β Jira needs project + issue type, GitHub needs only a repo, Azure DevOps needs project + team + area path + iteration path. A future target descriptor (each provider declaring its target fields, the editor rendering them) is the real fix, and it is the one place a core change is expected.
System β Integrations β Jira, at /system/integrations/jira.
-
/system/integrations/is just a folder with anindex.phpβ already pretty, no rewrite needed, same assystem/sso/. -
/system/integrations/<provider>is rewritten toprovider.php?provider=<key>by a folder-level.htaccess, house style copied fromapi/v1/.htaccess.^([a-z0-9-]+)/?$so both/jiraand/jira/work with noDirectorySlash301, behind!-f !-dguards so a real file always wins.
One shared provider.php, not a folder per provider. The form renders from the registry's credential_fields, so a tracker whose auth looks nothing like Jira's needs no change to the page, and its URL starts working the moment the connector is registered.
Accept updates from {name} lives in the connection modal, under Active, and gates the whole comment path.
β οΈ The column existed from V1 and nothing ever wrote it.integration_connections.inbound_enabledwas in the schema, inintegrationsListConnections(), and read by the service β but no endpoint set it and no control existed, so the feature it gates could never be switched on. It looked finished from every angle except the only one that counts.The lesson for the columns V1 shipped ahead of need (
ingress_mode,poll_interval_minutes,webhook_secret): a column with no writer is not "ready", it is a trap. When you build the feature, check the write path exists rather than assuming the schema implies it.
The connections list shows an Updates on badge beside Active when it is set. That was added because "is it even on?" otherwise cost one click per connection β and because the tickbox being inside the modal made it genuinely hard to find. poll_interval_minutes still has no UI and is schema-only.
The label is system.integrations.inbound_label with a {name} placeholder β the page is shared across providers, so it must not hardcode "Jira" the way the design doc's Jira-specific vocabulary (Β§5.0) does.
web.config has no <rewrite> section, so pretty URLs do not work on IIS β the same pre-existing gap as /login. The page is still reachable at provider.php?provider=jira.
-
.form-rowisdisplay: flexininbox.css. Reusing that class name puts labels beside inputs and wraps them mid-phrase. This page uses.int-fieldinstead β a scoped class beats an override war. -
Full-width settings pages need BOTH.
max-width: nonealone does nothing while an inheritedmargin: β¦ autois still centring it. There is no auto margin here at all. -
Use
showConfirm/showToast, notconfirm()and not a bespoke result box. Both are loaded on every analyst page byrenderWaffleMenuJS(). -
Icon buttons break
e.target. A click lands on the<path>inside the SVG, so read the attribute offclosest('[data-β¦]').
A linked issue is rendered as a pill in the existing Links strip, not in a panel of its own. A Jira issue is a link, and that strip is already where an analyst looks for "what else is this connected to". Reusing it meant no new layout, no new empty state, and no new place to remember.
The pill is tinted by status_category β the four categories every provider normalises onto β so nothing on the ticket screen knows Jira's vocabulary. api/tickets/get_email_detail.php adds tracker_links to the payload.
Links strip β Link toβ¦ β Issue tracker, which opens a modal that:
- lists only connections this ticket's company may use (
connections_for_ticket.phpapplies the same company rule the service enforces β the UI half, never the enforcement); - builds the description server-side in preview mode and shows it verbatim;
- escalates through
escalate_ticket.phpβintegrationsEscalate()β the same service the workflow action uses.
The first real ticket previewed produced a Jira description containing several hundred lines of the email's CSS. strip_tags() removes the tags of <style> but keeps its content, so a marketing-styled email dumps its whole stylesheet into the issue.
So the helper kills <style>/<script>/<head>/<title> outright, turns block ends into newlines, collapses the runs and CRLF pairs HTML mail is full of, and caps the length with a visible "(truncated)" marker. It has its own tests (Β§8, section 7) precisely because it is the sort of thing that rots silently.
-
Modals open with
classList.add('active'), neverstyle.display..modalis alreadydisplay: flexand hidden viavisibility/opacity, so setting display achieves nothing β the click just appears to do nothing at all. This shipped to Ed once and that is exactly how it presented. -
Bump
inbox.js?v=intickets/index.php. Edit the file without bumping and every browser keeps running the old one, which also looks like "the button does nothing".
tests/integrations/run.php β 252 assertions, no database, no network:
php tests/integrations/run.php
The trick is that payload building and response parsing are separate protected methods rather than being inlined into the HTTP calls, so the suite subclasses a provider and stubs httpRequest() with a queue of canned [code, body] pairs. Everything that decides correctness β the ADF-vs-wiki choice, status mapping, error extraction, paging, auth headers β is therefore provable offline.
"The asterisk did not become italics" proves nothing on its own; it is equally true of a renderer that dropped the text. So each escaping test also asserts the surrounding content survived.
This matters most for the company guard, which was verified in both directions by deliberate breakage:
| Breakage | Result |
|---|---|
company guard made permissive (return true) |
exactly the 5 refusal assertions fail |
company guard made refuse-everything (return false) |
exactly the 10 permission assertions fail |
comment-map guard disabled (already_imported never returned) |
exactly 3 fail β an echo is no longer recognised |
That last one is the one to repeat when touching Β§7d. Note what it does not prove on its own: a guard that dropped everything would also stop echoes, and would pass every negative assertion while being completely broken β which is why the positive controls beside it (a dev's comment imports, our own account's comment imports, a different id imports) carry the weight.
It has no database, so it cannot catch a wrong column name or a unique key that does not fire. Those were verified separately by running the real integrationsApplyCommentEvent() against a live dev database inside a transaction and rolling it back β which is how the write order, the unique key, the is_internal flag and the display join were each confirmed, along with a positive control that a genuine new comment still lands. Do the same for anything here that touches SQL.
A guard that refused everything would otherwise pass the whole negative half while being completely broken. Do the same for anything you add here.
That exercise also caught an assertion that fatalled rather than failing β hiding every test after it. Guard assertions that could receive the wrong type.
| Piece | State |
|---|---|
Settings screen (/system/integrations/jira) |
β built (#948) |
Workflow actions escalate_to_tracker / send_note_to_tracker
|
β built (#949) |
| Ticket pill and the manual escalate button | β built (#950) |
| Poll cron | β built (#951) |
| Comments tracker β ticket, echo suppression | β built (#954), proven against real Jira 2026-08-02; author-based suppression removed in #956 (Β§7d) |
| Comments ticket β tracker | β the action exists (#949); |
tracker.* triggers (inbound events firing workflows) |
V2 β designed (Β§5.2), not built |
| Webhooks (instant instead of the poll's ~5 min) | V2 β designed (Β§8.1), not built |
| Project routing, issue-type + priority mapping | β built (#957, Β§7e) |
| Attachments, custom fields, components, per-analyst tokens | V3, not built |
| Issues raised in the tracker creating tickets | V4 |
| GitHub, GitLab, Azure DevOps connectors | V5 |
The full parity scoreboard against HaloITSM β feature by feature, plus where we are already ahead and the build order β is Β§16 of the design doc (docs/design/external-issue-trackers.md in the app repo; local only, since docs/design/ is gitignored).
Inbound will be webhook or poll, and both must produce the same canonical events β polling is a degraded webhook, not a second pipeline. A self-hosted install behind a firewall often cannot receive an inbound call, which is the same wall the WhatsApp channel hit; the difference is that trackers all expose a queryable REST API, so the firewalled case needs no relay, it just polls.
The payoff, and the test of whether the engine is right:
- Write
includes/integrations/<Name>Provider.phpextendingIssueTrackerProvider. - Implement the four abstract methods.
renderDoc()is usually one line. - Map the tracker's states onto the four categories.
- Declare
capabilities(). - Add one line to the factory and one entry to
integrationsAvailableProviders()inintegrations.php.
That registry entry is what makes the rest free: it carries the provider's name, blurb, URL label and β importantly β its credential_fields, which the settings form renders from. So a tracker authenticating completely unlike Jira needs no change to the settings page, and its URL /system/integrations/<key> starts working immediately.
Adding GitHub should touch nothing else. If it needs a change to the link table, the service, the settings page or the workflow action, the abstraction was wrong.
project / issue_type args (Β§7a) β those are Jira-shaped and a provider with a different target arity will want the target descriptor building. That is expected, and it is the one place a core change is already anticipated rather than a failure of the design.
β οΈ Validate against Azure DevOps, not GitHub. GitHub is the easy one β everything will appear to fit and you will learn nothing. DevOps breaks the most assumptions: updates use JSON Patch documents rather than a fields object, states are per-process, targets are a tree of project + team + area path + iteration path, and the body is HTML. If the contract survives DevOps, the others are trivial.
This page and the code must never disagree. It documents decisions, not just mechanics, and a decision that changed in code but not here is worse than no documentation β someone will trust it.
If you change any of the following, update this page in the same commit:
- the abstract method list, or which methods are optional
- the four status categories, or how a provider maps onto them
-
IssueDoc's block types or any renderer's output shape - either table's columns, or what
tenant_idNULL means (Β§6) - the company guard's behaviour in any case
- the order of operations in
integrationsEscalate()(Β§7) -
the echo guard, or the order of writes in
integrationsApplyCommentEvent()(Β§7d) β the map row goes in first, and that is a correctness decision, not a style one - anything that reintroduces author-based suppression (Β§7d, #956) β it has been tried and it silently ate real comments
- the "first poll imports nothing" rule, or the lookback cap (Β§7d) β both are product promises made in the settings hint
- that imported comments are always internal (Β§7d)
- the workflow actions, their args, or the defaults in Β§7a
- the provider registry's shape (Β§10) β it is what keeps a new tracker cheap
- what is and is not built (Β§9), which is the first thing a reader checks
#948 and #949 shipped before this page was updated, and the Β§9 table then said "not built" about two things that were live. A wiki that is confidently wrong is worse than one that is missing β someone will trust it. Update in the same commit, not in the next one.
The design doc behind all of this is docs/design/external-issue-trackers.md in the app repo β local only, since docs/design/ is gitignored.
Jira-specific behaviour is on Jira Connector β Developer Guide, which has its own version of this 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)