-
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β#947.
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.
β οΈ Status: engine only. There is no user interface yet β no settings screen, no button on a ticket, no workflow action. What exists is the layer all of that will sit on. The roadmap is in Β§9.
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 β and the company guard (Β§6, Β§7) |
| π | 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) |
| β¨οΈ |
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 |
| ποΈ |
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php
|
integration_connections + integration_links, 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 |
151 assertions, none needing a live tracker (Β§8) |
| π |
CHANGELOG.local.md, this wiki |
logged as #945β#949 |
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. Nothing reads it yet. It is half of echo suppression: once comments sync both ways, an inbound event authored by this identity is our own write coming back and must be dropped rather than re-imported. It is populated now because back-filling it for links that already exist is miserable. -
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.
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.
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.
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.
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-β¦]').
tests/integrations/run.php β 151 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 |
|---|---|
guard made permissive (return true) |
exactly the 5 refusal assertions fail |
guard made refuse-everything (return false) |
exactly the 10 permission assertions fail |
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) |
tracker.* triggers (inbound events firing workflows) |
V2 |
| Ticket panel and the manual escalate button | not built |
| Poll cron | not built |
| Comment sync both ways, webhooks, echo suppression | V2 |
| Field / priority / issue-type mapping, attachments, per-analyst tokens | V3 |
| Issues raised in the tracker creating tickets | V4 |
| GitHub, GitLab, Azure DevOps connectors | V5 |
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 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)